From 7827b19c93b622c13ceb5f4817325afe3d2d6d30 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 18 Sep 2026 20:00:25 +0200 Subject: [PATCH 1/7] Take core 7.0.0, and edit with the editor the page now carries Core 7.0.0 moves editing into the rendered page: the page owns the edit mode, the operation log, undo and the refusals, and a pdf page gets an annotator. The app now drives that page instead of reloading it. - CoreLoader follows the API breaks: DecodeOptions, translate without a cache path, Document.edit, and csv and markdown files that hold a text file rather than being one. - CoreLoader asks the opened file what the user can change and returns an EditingKind: a document, a sheet, a plain text file, or a pdf to mark up. A document that can be written back is rendered with its editor, so the edit button only turns the page's mode on. - No document stays open between the render and the save. writeEdits opens the cached copy again and applies the page's payload through Document.edit, TextFile.writeEdited or PdfFile.annotate. - The edit mode's bar gets undo and redo, and a strip of tools under it: bold, italic, underline, strikethrough, text colour, highlight and size for a document, and five marking tools for a pdf. This is the layout of the viewer on the website. - Features.withAdvancedEditing is false in lite. Lite renders with the editing scope "paragraph", so the page refuses an edit that splits or merges a paragraph. Lite also shows the formatting tools locked, and the pdf button. Each of them offers Pro instead of acting. - Leaving the edit mode with unsaved edits asks to save or discard them. Before, the edits were dropped without a question. Known gap, in core: document.js drops text that an Android keyboard types, because the keyboard keeps a composition open. See the pull request. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01F6AQY2k86AaPq12nxBfN7A --- CLAUDE.md | 43 +- app/build.gradle | 9 + .../droid/background/DocumentParcelTest.kt | 8 +- .../app/opendocument/droid/test/CoreTest.kt | 182 ++++--- app/src/main/assets/editing-bridge.js | 128 +++++ .../droid/background/CoreLoader.kt | 264 +++++++---- .../droid/background/DocumentLoader.kt | 8 +- .../droid/background/DocumentRequest.kt | 6 +- .../droid/background/DocumentSaver.kt | 25 +- .../droid/background/EditingKind.kt | 25 + .../droid/background/FileIdentifier.kt | 4 +- .../droid/background/LoadedDocument.kt | 10 +- .../opendocument/droid/nonfree/Features.kt | 13 +- .../droid/ui/EditActionModeCallback.kt | 76 ++- .../droid/ui/activity/DocumentFragment.kt | 260 ++++++++-- .../droid/ui/activity/MainActivity.kt | 48 +- .../droid/ui/widget/EditingTools.kt | 448 ++++++++++++++++++ .../opendocument/droid/ui/widget/PageView.kt | 177 ++++++- app/src/main/res/drawable/bg_color_bar.xml | 7 + app/src/main/res/drawable/bg_color_swatch.xml | 7 + app/src/main/res/drawable/bg_editing_tool.xml | 16 + .../main/res/drawable/ic_arrow_drop_down.xml | 10 + app/src/main/res/drawable/ic_draw.xml | 10 + app/src/main/res/drawable/ic_format_bold.xml | 10 + .../main/res/drawable/ic_format_italic.xml | 10 + .../main/res/drawable/ic_format_squiggly.xml | 16 + .../res/drawable/ic_format_strikethrough.xml | 10 + .../res/drawable/ic_format_underlined.xml | 10 + app/src/main/res/drawable/ic_marker.xml | 10 + app/src/main/res/drawable/ic_redo.xml | 11 + app/src/main/res/drawable/ic_text_color.xml | 10 + app/src/main/res/drawable/ic_undo.xml | 11 + app/src/main/res/layout/fragment_document.xml | 11 + app/src/main/res/layout/item_editing_tool.xml | 28 ++ .../res/layout/item_editing_tool_chevron.xml | 13 + .../res/layout/item_editing_tool_text.xml | 15 + .../main/res/layout/view_color_palette.xml | 8 + .../main/res/layout/view_editing_tools.xml | 17 + app/src/main/res/menu/edit.xml | 18 +- app/src/main/res/values/strings.xml | 50 ++ gradle/libs.versions.toml | 2 +- 41 files changed, 1763 insertions(+), 281 deletions(-) create mode 100644 app/src/main/assets/editing-bridge.js create mode 100644 app/src/main/java/app/opendocument/droid/background/EditingKind.kt create mode 100644 app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt create mode 100644 app/src/main/res/drawable/bg_color_bar.xml create mode 100644 app/src/main/res/drawable/bg_color_swatch.xml create mode 100644 app/src/main/res/drawable/bg_editing_tool.xml create mode 100644 app/src/main/res/drawable/ic_arrow_drop_down.xml create mode 100644 app/src/main/res/drawable/ic_draw.xml create mode 100644 app/src/main/res/drawable/ic_format_bold.xml create mode 100644 app/src/main/res/drawable/ic_format_italic.xml create mode 100644 app/src/main/res/drawable/ic_format_squiggly.xml create mode 100644 app/src/main/res/drawable/ic_format_strikethrough.xml create mode 100644 app/src/main/res/drawable/ic_format_underlined.xml create mode 100644 app/src/main/res/drawable/ic_marker.xml create mode 100644 app/src/main/res/drawable/ic_redo.xml create mode 100644 app/src/main/res/drawable/ic_text_color.xml create mode 100644 app/src/main/res/drawable/ic_undo.xml create mode 100644 app/src/main/res/layout/item_editing_tool.xml create mode 100644 app/src/main/res/layout/item_editing_tool_chevron.xml create mode 100644 app/src/main/res/layout/item_editing_tool_text.xml create mode 100644 app/src/main/res/layout/view_color_palette.xml create mode 100644 app/src/main/res/layout/view_editing_tools.xml diff --git a/CLAUDE.md b/CLAUDE.md index 0281c8ea3e1d..8a715cadc31f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,13 +115,17 @@ and `src/review`, with a no-op of the same shape in `src/noAds` and `src/noRevie nothing proprietary and stays in `src/main`. A method added to one copy has to be added to the other, which `assembleDebug` catches - it builds all three. -Code that has to *ask* reads `Features`, never the flavor name: `Features.withAds` is the -one question anything asks today, and `LINKS_ADS` behind it sits in `src/ads` and -`src/noAds` next to the classes it stands for, so the flag cannot end up in a build whose -code says otherwise. Do not add a `BuildConfig.FLAVOR` comparison back - it was what made -`BillingManager` miss foss - and do not name a flag after a behaviour it only implies. The -resource bool `DISABLE_TRACKING` was both mistakes at once: there is no tracking to -disable, `AnalyticsManager` and `CrashManager` write to logcat and nowhere else. +Code that has to *ask* reads `Features`, never the flavor name. `Features.withAds` comes +from `LINKS_ADS`, which sits in `src/ads` and `src/noAds` next to the classes it stands for, +so the flag cannot end up in a build whose code says otherwise. `Features.withAdvancedEditing` +is what pro is sold on - an edit that splits or merges a paragraph, formatting, marking up a +pdf - and is a `buildConfigField` per flavor, because no library stands behind it: false in +lite, true in pro and foss. Everything else the core can edit is in every build. + +Do not add a `BuildConfig.FLAVOR` comparison back - it was what made `BillingManager` miss +foss - and do not name a flag after a behaviour it only implies. The resource bool +`DISABLE_TRACKING` was both mistakes at once: there is no tracking to disable, +`AnalyticsManager` and `CrashManager` write to logcat and nowhere else. Those two take no switch at all, which is why `DocumentLoader` just constructs them. Ads and billing are what `MainActivity.initializeManagers` gates, on `Features.withAds` *and* @@ -296,19 +300,34 @@ deck opened in portrait keeps a portrait-sized slide in a landscape screen. `ini ### Editability comes from the core, never from a mime type -`Document.isEditable()`/`isSavable()` decides whether `DocumentFragment` offers the Edit -button, carried on `LoadedDocument.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. +`CoreLoader.editingOf` asks the opened file what the user can change, and the answer rides on +`LoadedDocument.editing` as an `EditingKind`: `DOCUMENT` for a text document or a +presentation, `SHEET`, `TEXT` for a plain file, `ANNOTATION` for a pdf, `NONE`. It is the +file's own answer - `Document.isEditable()`/`isSavable()`, `TextFile.isSavable()`, +`PdfFile.isAnnotatable()` - so a decrypted document or a repaired pdf says no. Do not +reintroduce a list of editable formats in the UI. `DecodedFile.capabilities()` is asked first, as a shortcut: opening a document costs a second parse, so a format declaring no `edit`/`save` is never opened to be told no. It is an -upper bound - the document still answers. +upper bound - the file still answers. Decryption is the same shape. `capabilities().decrypt` says whether a password is worth asking for, and `CoreLoader.host` refuses an encrypted `.doc`, `.ppt` or `.xls` on it rather than raising a dialog no password can close. The app must not learn that list for itself. +**The editor is in the page, and it is always there.** A document the core can write back is +rendered with `HtmlConfig.editable`, and the edit button only calls `odr.editing.enable()` - +no second render, so the reader stays where they were. The page owns the operation log, undo +and the refusals; `editing-bridge.js` (injected by `PageView` on every page load) forwards its +callbacks. Lite narrows `HtmlConfig.editingScope` to `PARAGRAPH`, and the page refuses the +rest with `outOfScope`, which `DocumentFragment` answers with the offer of pro. A pdf needs no +scaffolding: every pdf page carries `odr.annotation`. + +**Nothing is held open between the render and the save.** `CoreLoader.writeEdits` opens the +cached copy again and applies the page's payload with the call its kind takes - +`Document.edit` and `save`, `TextFile.writeEdited`, `PdfFile.annotate`. An edit that throws +halfway leaves the document it was applied to half changed, so a retry must not start from it. + ### Storage access The app declares **no storage permission**, only `INTERNET`, and has to stay that way: diff --git a/app/build.gradle b/app/build.gradle index 82dee9664e71..87c67f96af07 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,7 +101,12 @@ android { } productFlavors { + // the edits a document takes in one paragraph, a sheet's cells and a plain text file are + // in every flavor; ADVANCED_EDITING adds what reaches past that - a paragraph split or + // merged, formatting, and marking up a pdf - and is what pro is sold on lite { + buildConfigField 'boolean', 'ADVANCED_EDITING', 'false' + if (hasReleaseSigning) { signingConfig = signingConfigs.releaseLite } @@ -110,6 +115,8 @@ android { pro { applicationIdSuffix = ".pro" + buildConfigField 'boolean', 'ADVANCED_EDITING', 'true' + if (hasReleaseSigning) { signingConfig = signingConfigs.releasePro } @@ -120,6 +127,8 @@ android { foss { applicationIdSuffix = ".foss" + buildConfigField 'boolean', 'ADVANCED_EDITING', 'true' + if (hasReleaseSigning) { signingConfig = signingConfigs.releaseLite } diff --git a/app/src/androidTest/java/app/opendocument/droid/background/DocumentParcelTest.kt b/app/src/androidTest/java/app/opendocument/droid/background/DocumentParcelTest.kt index 0e996f45f478..434f5337979f 100644 --- a/app/src/androidTest/java/app/opendocument/droid/background/DocumentParcelTest.kt +++ b/app/src/androidTest/java/app/opendocument/droid/background/DocumentParcelTest.kt @@ -106,7 +106,7 @@ class DocumentParcelTest { ), // the middle sheet is the only one the budget cut listOf(null, SheetCut(80000, 12, 8333, 12), null), - isEditable = true, + editing = EditingKind.DOCUMENT, readsAsDocument = true, ) @@ -116,7 +116,7 @@ class DocumentParcelTest { assertEquals("budget.ods", restored.file.filename) assertEquals(listOf("hey", "ho", "Sheet3"), restored.partTitles) assertEquals(document.partUris, restored.partUris) - assertTrue(restored.isEditable) + assertEquals(EditingKind.DOCUMENT, restored.editing) assertTrue(restored.readsAsDocument) assertNull(restored.partCuts[0]) @@ -147,7 +147,7 @@ class DocumentParcelTest { listOf(null), listOf(Uri.parse("http://localhost:29665/file/odr/document.html")), listOf(null), - isEditable = false, + editing = EditingKind.NONE, readsAsDocument = true, ), LoadedDocument.CREATOR, @@ -156,7 +156,7 @@ class DocumentParcelTest { assertEquals(1, restored.partTitles.size) assertNull(restored.partTitles[0]) assertNull(restored.partCuts[0]) - assertEquals(false, restored.isEditable) + assertEquals(EditingKind.NONE, restored.editing) assertTrue(restored.readsAsDocument) } 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 ee7e1b2a6ae4..031ea4f27826 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt @@ -6,6 +6,7 @@ import androidx.test.platform.app.InstrumentationRegistry import app.opendocument.core.FileType import app.opendocument.core.OdrException import app.opendocument.droid.background.CoreLoader +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.background.SpreadsheetBudget import app.opendocument.droid.nonfree.CrashManager import java.io.File @@ -25,44 +26,100 @@ class CoreTest { get() = checkNotNull(sharedLoader) { "the core loader was not started" } @Test - fun test() { - val views = - coreLoader.host( - prefix = "test", - inputPath = testFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, - editable = true, - keepDocument = true, - ) - Assert.assertFalse("hosting the ODT file should produce a view", views.isEmpty()) + fun testOdtEdit() { + assertEditRoundTrips("odt-edit", testFile) + } - val htmlDiff = - "{\"modifiedText\":{\"/child:1/child:0\":\"This is a simple testoooo document to" + - " demonstrate the DocumentLoader example!\",\"/child:3/child:0\":\"This is a" + - " simple testaaaa document to demonstrate the DocumentLoader example!\"}}" + @Test + fun testDocxEdit() { + assertEditRoundTrips("docx-edit", docxTestFile) + } - val result = coreLoader.edit(htmlDiff, File(cacheDir(), "result").path) + @Test + fun testPptxEdit() { + assertEditRoundTrips("pptx-edit", pptxTestFile) + } + + /** + * Writes one run of [file] the way the page's editor does - an envelope naming the run by the + * id the render put on it - and reads the saved file back. + */ + private fun assertEditRoundTrips(prefix: String, file: File) { + val html = + URL(coreLoader.host(prefix, file.absolutePath, askEditing = true)[0].url).readText() + + val id = + checkNotNull(RUN_ID.find(html)) { "the editable render of ${file.name} names no run" } + .groupValues[1] + + val payload = """{"version":2,"ops":[{"op":"setText","id":$id,"text":"$EDITED"}]}""" + + val result = + coreLoader.writeEdits( + file.absolutePath, + null, + null, + payload, + File(cacheDir(), "$prefix-result").path, + ) Assert.assertTrue("the edited document should have been saved", result.isFile) + + val saved = URL(coreLoader.host("$prefix-saved", result.absolutePath)[0].url).readText() + Assert.assertTrue("the saved ${file.name} should carry the edit", saved.contains(EDITED)) + + result.delete() } + /** A pdf takes marks, which the core appends to a copy of it as annotations. */ @Test - fun testDocxEdit() { - val views = - coreLoader.host( - prefix = "docx-edit", - inputPath = docxTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, - editable = true, - keepDocument = true, + fun testPdfAnnotation() { + val payload = + """{"version":1,"annotations":[{"page":0,"type":"highlight",""" + + """"quads":[[72,720,200,720,72,700,200,700]],"color":[1,0.9,0.2]}]}""" + + val result = + coreLoader.writeEdits( + pdfTestFile.absolutePath, + null, + null, + payload, + File(cacheDir(), "pdf-annotate-result").path, ) - Assert.assertFalse("hosting the DOCX file should produce a view", views.isEmpty()) - val htmlDiff = - "{\"modifiedText\":{\"/child:16/child:0/child:0\":\"Outasdfsdafdline\",\"/child:24/child:0/child:0\":\"Colorasdfasdfasdfed" + - " Line\",\"/child:6/child:0/child:0\":\"Text hello world!\"}}" + Assert.assertTrue( + "the annotated pdf should hold more than the original", + result.length() > pdfTestFile.length(), + ) + Assert.assertTrue( + "the annotation should have been appended", + String(result.readBytes(), Charsets.ISO_8859_1).contains("/Highlight"), + ) - val result = coreLoader.edit(htmlDiff, File(cacheDir(), "result_docx").path) - Assert.assertTrue("the edited document should have been saved", result.isFile) + result.delete() + } + + /** A plain text file is edited whole, and saved as utf-8. */ + @Test + fun testTextEdit() { + val text = File(cacheDir(), "plain.txt") + text.writeText("before\n") + extracted += text + + coreLoader.host("text-edit", text.absolutePath, askEditing = true) + Assert.assertEquals(EditingKind.TEXT, coreLoader.editing) + + val result = + coreLoader.writeEdits( + text.absolutePath, + null, + null, + """{"version":2,"ops":[{"op":"setContent","text":"$EDITED"}]}""", + File(cacheDir(), "text-edit-result").path, + ) + + Assert.assertEquals(EDITED, result.readText()) + + result.delete() } /** @@ -75,7 +132,6 @@ class CoreTest { coreLoader.host( prefix = "pptx-test", inputPath = pptxTestFile.absolutePath, - cachePath = File(cacheDir(), "pptx_cache").path, ) Assert.assertFalse("hosting the PPTX file should produce a view", views.isEmpty()) } @@ -86,7 +142,6 @@ class CoreTest { coreLoader.host( prefix = "doc-test", inputPath = docTestFile.absolutePath, - cachePath = File(cacheDir(), "doc_cache").path, ) Assert.assertFalse("hosting the DOC file should produce a view", views.isEmpty()) } @@ -97,7 +152,6 @@ class CoreTest { coreLoader.host( prefix = "ppt-test", inputPath = pptTestFile.absolutePath, - cachePath = File(cacheDir(), "ppt_cache").path, ) Assert.assertFalse("hosting the PPT file should produce a view", views.isEmpty()) } @@ -108,42 +162,35 @@ class CoreTest { coreLoader.host( prefix = "xls-test", inputPath = 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. + * What the core lets the user change in each of the formats it renders - the answer + * `DocumentFragment` puts the Edit button up by, and picks the tools with. */ @Test fun testEditableFormats() { - assertEditable("odt-editable", testFile, true) - assertEditable("docx-editable", 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", docTestFile, false) - assertEditable("ppt-editable", pptTestFile, false) - assertEditable("xls-editable", xlsTestFile, false) - assertEditable("pptx-editable", pptxTestFile, false) - assertEditable("ods-editable", spreadsheetTestFile, false) + assertEditing("odt-editable", testFile, EditingKind.DOCUMENT) + assertEditing("docx-editable", docxTestFile, EditingKind.DOCUMENT) + assertEditing("pptx-editable", pptxTestFile, EditingKind.DOCUMENT) + assertEditing("ods-editable", spreadsheetTestFile, EditingKind.SHEET) + assertEditing("pdf-editable", pdfTestFile, EditingKind.ANNOTATION) + + // the core declares the three legacy binary formats read only + assertEditing("doc-editable", docTestFile, EditingKind.NONE) + assertEditing("ppt-editable", pptTestFile, EditingKind.NONE) + assertEditing("xls-editable", xlsTestFile, EditingKind.NONE) } - 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, - ) + private fun assertEditing(prefix: String, file: File, expected: EditingKind) { + coreLoader.host(prefix = prefix, inputPath = file.absolutePath, askEditing = true) Assert.assertEquals( - "the core should report ${file.name} as ${if (expected) "editable" else "read only"}", + "what the core lets the user change in ${file.name}", expected, - coreLoader.isDocumentEditable, + coreLoader.editing, ) } @@ -153,7 +200,6 @@ class CoreTest { coreLoader.host( prefix = "password-test-no-pw", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, ) } } @@ -164,7 +210,6 @@ class CoreTest { coreLoader.host( prefix = "password-test-wrong-pw", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, password = "wrongpassword", ) } @@ -176,7 +221,6 @@ class CoreTest { coreLoader.host( prefix = "password-test-correct-pw", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, password = "passwort", ) Assert.assertFalse("the decrypted document should produce a view", views.isEmpty()) @@ -192,15 +236,14 @@ class CoreTest { coreLoader.host( prefix = "password-test-editable", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "password_editable").path, password = "passwort", - editable = true, - keepDocument = true, + askEditing = true, ) - Assert.assertFalse( + Assert.assertEquals( "a decrypted document should not be editable", - coreLoader.isDocumentEditable, + EditingKind.NONE, + coreLoader.editing, ) } @@ -214,7 +257,6 @@ class CoreTest { coreLoader.host( prefix = "encrypted-doc", inputPath = encryptedDocTestFile.absolutePath, - cachePath = File(cacheDir(), "encrypted_doc_cache").path, ) } @@ -223,7 +265,6 @@ class CoreTest { coreLoader.host( prefix = "encrypted-doc-pw", inputPath = encryptedDocTestFile.absolutePath, - cachePath = File(cacheDir(), "encrypted_doc_cache").path, password = "passwort", ) } @@ -236,7 +277,6 @@ class CoreTest { coreLoader.host( prefix = "encrypted-odt-prompts", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, ) } } @@ -267,7 +307,6 @@ class CoreTest { coreLoader.host( prefix = "odt-called-pdf", inputPath = testFile.absolutePath, - cachePath = File(cacheDir(), "odt_called_pdf").path, declaredType = FileType.PORTABLE_DOCUMENT_FORMAT, ) @@ -280,7 +319,6 @@ class CoreTest { coreLoader.host( prefix = prefix, inputPath = file.absolutePath, - cachePath = File(cacheDir(), prefix).path, declaredType = declaredType, ) @@ -304,7 +342,6 @@ class CoreTest { coreLoader.host( prefix = "big-sheet", inputPath = generateCsv(rows, columns).absolutePath, - cachePath = File(cacheDir(), "big_sheet_cache").path, ) val cut = @@ -323,7 +360,6 @@ class CoreTest { coreLoader.host( prefix = "whole-sheet", inputPath = spreadsheetTestFile.absolutePath, - cachePath = File(cacheDir(), "whole_sheet_cache").path, ) views.forEach { Assert.assertNull("nothing was cut from " + it.name, it.sheetCut) } @@ -335,7 +371,6 @@ class CoreTest { coreLoader.host( prefix = "spreadsheet-test", inputPath = spreadsheetTestFile.absolutePath, - cachePath = File(cacheDir(), "spreadsheet_cache").path, ) Assert.assertEquals("ODS file should contain 3 sheets", 3, views.size) @@ -363,6 +398,11 @@ class CoreTest { private lateinit var encryptedDocTestFile: File private lateinit var pdfTestFile: File + /** The address the editable render puts on a run of text. */ + private val RUN_ID = Regex("""]*data-odr-id="(\d+)"""") + + private const val EDITED = "Edited by CoreTest" + /** What a document saved straight out of a browser carries in front of itself. */ private const val HTTP_PREAMBLE = "HTTP/1.0 200 OK\r\n" + diff --git a/app/src/main/assets/editing-bridge.js b/app/src/main/assets/editing-bridge.js new file mode 100644 index 000000000000..f1c07f815276 --- /dev/null +++ b/app/src/main/assets/editing-bridge.js @@ -0,0 +1,128 @@ +// Injected by PageView into every page the core serves, after the page's own scripts. It wires +// the page's editing callbacks to the app's bridge, and holds the one thing the app cannot see from +// outside: whether text is selected when a marking tool is pressed. +// +// The half of OpenDocument.website's frame-bridge.js that an app needs, over addJavascriptInterface +// rather than postMessage. +(function () { + "use strict"; + + var odr = window.odr; + var bridge = window.paragraphListener; + + // a page with no scripts of the core's, a page this was injected into already, or a page the + // bridge is not attached to + if (!odr || !bridge || odr.androidEditing) { + return; + } + + var annotation = odr.annotation || null; + + // the page's callbacks, forwarded: the page owns what they mean, the app what they say + odr.onEditChange = function (event) { + bridge.editChanged(!!event.dirty, !!event.canUndo, !!event.canRedo); + }; + odr.onEditRefused = function (event) { + bridge.editRefused(String(event.reason || "")); + }; + odr.onSelectionChange = function (style) { + bridge.selectionChanged(JSON.stringify(style || {})); + }; + odr.onCellsStale = function (detail) { + bridge.cellsStale(detail && detail.cells ? detail.cells.length : 0); + }; + + // the annotator has no callback of its own, so the count of pending marks is reported after + // every gesture that can change it. A mark taken from a selection settles 50ms after the pointer + // lifts; this waits a little longer + var reportedMarks = -1; + + function reportMarks() { + var count = annotation.list().length; + if (count === reportedMarks) { + return; + } + reportedMarks = count; + bridge.marksChanged(count); + } + + function reportMarksSoon() { + window.setTimeout(reportMarks, 120); + } + + if (annotation) { + // an armed tool marks a selection as it is made, which is what a touch screen needs: with a + // selection standing, the selection's own toolbar is over the page + annotation.setOptions({ markOnSelection: true }); + document.addEventListener("pointerup", reportMarksSoon); + document.addEventListener("pointercancel", reportMarksSoon); + document.addEventListener("selectionchange", reportMarksSoon); + } + + function hasSelection() { + var selection = window.getSelection(); + return !!selection && !selection.isCollapsed && selection.toString().length > 0; + } + + /// Marks the selection once with @p tool, and leaves no tool armed. + function markOnce(tool) { + annotation.setTool(tool); + annotation.mark(); + // disarmed before the selection is cleared, so the clear cannot mark it a second time + annotation.setTool(null); + var selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + } + reportMarks(); + } + + odr.androidEditing = { + /// A tool button was pressed. With text selected, the tool marks that selection once. Without + /// one, the press arms the tool, and a second press disarms it. @p rgb is 0..1 per component. + /// Answers the tool left armed, or null. + tool: function (tool, rgb, width) { + if (!annotation) { + return null; + } + annotation.setColor(rgb); + annotation.setWidth(width); + if (tool !== "ink" && hasSelection()) { + markOnce(tool); + } else if (annotation.getTool() === tool) { + annotation.setTool(null); + } else { + annotation.setTool(tool); + } + return annotation.getTool(); + }, + + /// A new colour for @p tool: marks a selection once, recolours the tool if it is armed. + recolor: function (tool, rgb, width) { + if (!annotation) { + return null; + } + if (tool !== "ink" && hasSelection()) { + annotation.setColor(rgb); + annotation.setWidth(width); + markOnce(tool); + } else if (annotation.getTool() === tool) { + annotation.setColor(rgb); + } + return annotation.getTool(); + }, + + disarm: function () { + if (annotation) { + annotation.setTool(null); + } + }, + + undoMark: function () { + if (annotation) { + annotation.undo(); + reportMarks(); + } + }, + }; +})(); 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 ca2d74ae83f5..61f5956e1074 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -4,47 +4,49 @@ import android.content.Context import android.net.Uri import android.system.Os import android.util.Log -import app.opendocument.core.DecodePreference +import app.opendocument.core.DecodeOptions import app.opendocument.core.DecodedFile -import app.opendocument.core.Document import app.opendocument.core.DocumentType import app.opendocument.core.FileCategory import app.opendocument.core.FileType import app.opendocument.core.Html import app.opendocument.core.HtmlColorScheme import app.opendocument.core.HtmlConfig +import app.opendocument.core.HtmlEditingScope import app.opendocument.core.HtmlView import app.opendocument.core.HttpServer import app.opendocument.core.Odr import app.opendocument.core.OdrException import app.opendocument.core.TableDimensions +import app.opendocument.core.TextEncoding +import app.opendocument.core.TextFile import app.opendocument.droid.nonfree.CrashManager +import app.opendocument.droid.nonfree.Features import java.io.File import java.io.IOException +import org.json.JSONObject /** * Loads documents through odrcore and publishes them on a local http server. * - * Owns the process wide core state: the one-time initialization, the single http server and the - * currently open [Document] that [retranslate] edits. + * Owns the process wide core state: the one-time initialization and the single http server. No + * document is held open between a render and a save: [writeEdits] opens the cached copy again. */ class CoreLoader(private val context: Context) { private lateinit var crashManager: CrashManager - private var document: Document? = null - private var lastInputPath: String? = null private var lastDocumentType: DocumentType = DocumentType.UNKNOWN /** Counts the renders, so each one publishes under a prefix of its own - see [render]. */ private var renderCount = 0 /** - * Whether the document [host] last opened is one [edit] can do something with - the core's own - * answer, since [host] only keeps a document that reports itself editable and savable. + * What the user can change in the document [host] last opened, where it was asked to find out. + * The core's own answer - see [editingOf]. */ - val isDocumentEditable: Boolean - get() = document != null + var editing: EditingKind = EditingKind.NONE + private set /** * Whether the core reads what [host] last opened as a document rather than only showing it - @@ -76,21 +78,14 @@ class CoreLoader(private val context: Context) { checkNotNull(FileCache.getCacheFile(context, file.cacheUri)) { "not a cached file: " + file.cacheUri } - val cacheDirectory = FileCache.getCacheDirectory(cachedFile) - - val coreCacheDirectory = File(cacheDirectory, "core_cache") - - lastInputPath = cachedFile.path val views = host( prefix = "odr" + renderCount++, inputPath = cachedFile.path, - cachePath = coreCacheDirectory.path, password = request.password, - editable = request.editable, paging = PaginationSetting.isEnabled(context), - keepDocument = true, + askEditing = true, declaredType = declaredType(file), ) @@ -100,7 +95,7 @@ class CoreLoader(private val context: Context) { views.map { it.name }, views.map { Uri.parse(it.url) }, views.map { it.sheetCut }, - isDocumentEditable, + editing, readsAsDocument, ) } @@ -109,17 +104,16 @@ class CoreLoader(private val context: Context) { * Opens [inputPath], translates it to html and publishes it on the shared http server under * [prefix], replacing whatever was published before. * - * [keepDocument] retains the decoded document for [retranslate]; [declaredType] is what the - * document is called - see [openFile]. + * [askEditing] finds out what the user can change and sets [editing]; a page the core can write + * back is rendered with the editor in it, so the edit mode needs no second render. + * [declaredType] is what the document is called - see [openFile]. */ fun host( prefix: String, inputPath: String, - cachePath: String, password: String? = null, - editable: Boolean = false, paging: Boolean = false, - keepDocument: Boolean = false, + askEditing: Boolean = false, declaredType: FileType? = null, ): List { val server = checkNotNull(sharedServer) { "core server is not running" } @@ -128,20 +122,7 @@ class CoreLoader(private val context: Context) { server.clear() - var file = openFile(inputPath, declaredType) - - if (file.passwordEncrypted()) { - // the core's answer, not a list of ours: a legacy .doc, .ppt or .xls has no way in - // whatever the password, so the prompt would be a dialog that can never close - if (!file.capabilities().decrypt) { - throw UndecryptableFile(inputPath) - } - - if (password == null) { - throw OdrException.FileEncrypted(inputPath) - } - file = file.decrypt(password) - } + val file = openDecrypted(inputPath, password, declaredType) Log.i(TAG, "type=" + Odr.fileTypeToString(file.fileType())) @@ -150,37 +131,30 @@ class CoreLoader(private val context: Context) { // the core opens text it cannot name a charset for and only fails once a page is // rendered - on the server thread, long after this reported success. so ask now - if (file.isTextFile && file.asTextFile().charset() == null) { + if (!hasKnownEncoding(file)) { throw OdrException.UnsupportedFileType("no charset could be detected: $inputPath") } - if (keepDocument) { - closeDocument() - - // an upper bound the core answers without decoding, so a format that declares no - // editing is not opened just to be told no - val capabilities = file.capabilities() - - if (file.isDocumentFile && capabilities.edit && capabilities.save) { - // TODO this will cause a second load - val document = file.asDocumentFile().document() - - // the document itself is the precise answer, and a read only one held open buys - // that second parse and nothing else - if (document.isEditable && document.isSavable) { - this.document = document - } else { - document.close() - } - } - } + editing = if (askEditing) editingOf(file) else EditingKind.NONE val htmlConfig = HtmlConfig() htmlConfig.embedImages = false htmlConfig.embedShippedResources = true htmlConfig.relativeResourcePaths = false htmlConfig.textDocumentMargin = paging - htmlConfig.editable = editable + + // the scaffolding only: the mode starts off, and odr.editing.enable() is what the edit + // button calls. a pdf needs none of it - every pdf page carries odr.annotation + htmlConfig.editable = + editing == EditingKind.TEXT || + editing == EditingKind.DOCUMENT || + editing == EditingKind.SHEET + + // an edit that splits or merges a paragraph, and formatting, are pro's. the page refuses + // them in lite with outOfScope, which DocumentFragment answers with the offer + htmlConfig.editingScope = + if (Features.withAdvancedEditing) HtmlEditingScope.DOCUMENT + else HtmlEditingScope.PARAGRAPH // both schemes, each behind prefers-color-scheme, rather than the one it is being read in // now: this is decided while translating, and darkening is turned on and off over the open @@ -193,11 +167,7 @@ class CoreLoader(private val context: Context) { htmlConfig.spreadsheetCellLimit = SpreadsheetBudget.cells(context) htmlConfig.spreadsheetLimitByContent = true - val cacheDirectory = File(cachePath) - cacheDirectory.deleteRecursively() - cacheDirectory.mkdirs() - - val service = Html.translate(file, cachePath, htmlConfig) + val service = Html.translate(file, htmlConfig) server.connectService(service, prefix) return selectViews(file, service.listViews()).map { view -> @@ -253,7 +223,7 @@ class CoreLoader(private val context: Context) { if ( declaredType == null || declaredType == detected.fileType() || - !detected.isTextFile || + textOf(detected) == null || !nameOutranksText(declaredType) ) { return detected @@ -276,25 +246,56 @@ class CoreLoader(private val context: Context) { /** [inputPath] opened as [type], or null where it is not one after all. */ private fun openAs(inputPath: String, type: FileType): DecodedFile? = try { - Odr.open(inputPath, DecodePreference().apply { asFileType = type }) + Odr.open(inputPath, DecodeOptions().apply { asFileType = type }) } catch (e: Throwable) { Log.i(TAG, "not a " + Odr.fileTypeToString(type)) null } - /** The document with [htmlDiff] applied, written to a file of ours. Null if that failed. */ - fun retranslate(request: DocumentRequest, file: IdentifiedFile, htmlDiff: String): File? { - try { - if (document == null) { - // nothing is held open after a rebuild, so open it again before editing it - render(request, file) - } + /** [openFile], and decrypted with [password] where the file is encrypted. */ + private fun openDecrypted( + inputPath: String, + password: String?, + declaredType: FileType?, + ): DecodedFile { + val file = openFile(inputPath, declaredType) + + if (!file.passwordEncrypted()) { + return file + } + + // the core's answer, not a list of ours: a legacy .doc, .ppt or .xls has no way in + // whatever the password, so the prompt would be a dialog that can never close + if (!file.capabilities().decrypt) { + throw UndecryptableFile(inputPath) + } + + if (password == null) { + throw OdrException.FileEncrypted(inputPath) + } - val inputFile = File(checkNotNull(lastInputPath)) - val inputCacheDirectory = FileCache.getCacheDirectory(inputFile) + return file.decrypt(password) + } - return edit(htmlDiff, File(inputCacheDirectory, "retranslate").path) + /** + * The document with [payload] from the page applied, written to a file of ours. Null if that + * failed. + */ + fun writeEdits(request: DocumentRequest, file: IdentifiedFile, payload: String): File? { + try { + val cachedFile = + checkNotNull(FileCache.getCacheFile(context, file.cacheUri)) { + "not a cached file: " + file.cacheUri + } + + return writeEdits( + cachedFile.path, + request.password, + declaredType(file), + payload, + File(FileCache.getCacheDirectory(cachedFile), "edited").path, + ) } catch (e: Throwable) { crashManager.log(e) @@ -303,23 +304,85 @@ class CoreLoader(private val context: Context) { } /** - * Applies [htmlDiff] to the document currently held open by [host] and saves it next to - * [outputPathPrefix], with the extension that matches the document's own file type. + * Opens [inputPath] again, applies [payload] and writes the result next to [outputPathPrefix], + * with the extension of the file's own type. + * + * Opened again rather than held open since the render: an edit that throws halfway leaves the + * document it was applied to half changed, and a second attempt must not start from that. + */ + fun writeEdits( + inputPath: String, + password: String?, + declaredType: FileType?, + payload: String, + outputPathPrefix: String, + ): File { + openDecrypted(inputPath, password, declaredType).use { file -> + // the file type's extension, not [Odr.fileTypeToString], which is its name - and a + // name like "ooxml_encrypted" is not something a file can be called + val extension = Odr.fileExtensionByFileType(file.fileType()) + val outputFile = File("$outputPathPrefix.$extension") + + Log.d(TAG, "edit payload: $payload") + + when (editingOf(file)) { + EditingKind.NONE -> throw IOException("cannot be written back: $inputPath") + EditingKind.ANNOTATION -> outputFile.writeBytes(file.asPdfFile().annotate(payload)) + EditingKind.TEXT -> outputFile.writeBytes(file.asTextFile().writeEdited(payload)) + EditingKind.DOCUMENT, + EditingKind.SHEET -> + file.asDocumentFile().document().use { document -> + // an envelope with no operations is refused, and a save with nothing to + // apply is the file as it was + if (JSONObject(payload).getJSONArray("ops").length() > 0) { + document.edit(payload) + } + + document.save(outputFile.path) + } + } + + return outputFile + } + } + + /** + * What the user can change in [file]. The format's capabilities come first, answered without + * decoding, so a format that declares no editing is not opened just to be told no. The file + * itself is the precise answer: a pdf repaired on open takes no marks, a document decrypted + * from a password cannot be saved. */ - fun edit(htmlDiff: String, outputPathPrefix: String): File { - val document = checkNotNull(document) { "no editable document is open" } + private fun editingOf(file: DecodedFile): EditingKind { + val capabilities = file.capabilities() + + if (file.isPdfFile) { + return if (capabilities.annotate && file.asPdfFile().isAnnotatable) { + EditingKind.ANNOTATION + } else { + EditingKind.NONE + } + } - // the file type's extension, not [Odr.fileTypeToString], which is its name - and a name - // like "ooxml_encrypted" is not something a file can be called - val extension = Odr.fileExtensionByFileType(document.fileType()) - val outputFile = File("$outputPathPrefix.$extension") + if (!capabilities.edit || !capabilities.save) { + return EditingKind.NONE + } - Log.d(TAG, "HTML diff: $htmlDiff") + if (file.isTextFile) { + return if (file.asTextFile().isSavable) EditingKind.TEXT else EditingKind.NONE + } - Html.edit(document, htmlDiff) - document.save(outputFile.path) + if (!file.isDocumentFile) { + return EditingKind.NONE + } - return outputFile + file.asDocumentFile().document().use { document -> + if (!document.isEditable || !document.isSavable) { + return EditingKind.NONE + } + + return if (document.documentType() == DocumentType.SPREADSHEET) EditingKind.SHEET + else EditingKind.DOCUMENT + } } /** @@ -330,13 +393,6 @@ class CoreLoader(private val context: Context) { */ fun close() { sharedServer?.clear() - - closeDocument() - } - - private fun closeDocument() { - document?.close() - document = null } /** @@ -457,6 +513,22 @@ class CoreLoader(private val context: Context) { coreInitialized = true } + /** + * The text [file] is read as, or null where it is not text. A csv and a markdown file hold + * one rather than being one, and are text for every question asked here. + */ + fun textOf(file: DecodedFile): TextFile? = + when { + file.isTextFile -> file.asTextFile() + file.isCsvFile -> file.asCsvFile().textFile() + file.isMarkdownFile -> file.asMarkdownFile().textFile() + else -> null + } + + /** False only for text whose encoding the core cannot name. */ + fun hasKnownEncoding(file: DecodedFile): Boolean = + textOf(file)?.let { it.encoding() != TextEncoding.UNKNOWN } ?: true + /** * Spreadsheets show one tab per sheet; every other format only shows the full "document" * view without tabs (if the service provides one - e.g. plain text and image files only diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentLoader.kt b/app/src/main/java/app/opendocument/droid/background/DocumentLoader.kt index 17e5fa4dab0f..1f5609eb894a 100644 --- a/app/src/main/java/app/opendocument/droid/background/DocumentLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/DocumentLoader.kt @@ -74,8 +74,8 @@ class DocumentLoader(application: Application) : AndroidViewModel(application) { backgroundHandler.post { renderSync(request, file) } } - fun save(document: LoadedDocument, target: Uri, htmlDiff: String?) { - backgroundHandler.post { saveSync(document, target, htmlDiff) } + fun save(document: LoadedDocument, target: Uri, payload: String?) { + backgroundHandler.post { saveSync(document, target, payload) } } /** @@ -197,9 +197,9 @@ class DocumentLoader(application: Application) : AndroidViewModel(application) { } } - private fun saveSync(document: LoadedDocument, target: Uri, htmlDiff: String?) { + private fun saveSync(document: LoadedDocument, target: Uri, payload: String?) { try { - documentSaver.save(document, target, htmlDiff) + documentSaver.save(document, target, payload) deliver { it.onSaveSuccess(target) } } catch (e: Throwable) { diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt b/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt index a8978224cf51..137511939954 100644 --- a/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt +++ b/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt @@ -12,7 +12,11 @@ import android.os.Parcelable */ class DocumentRequest(val uri: Uri, val persistentUri: Boolean) : Parcelable { - /** Whether the html is rendered for editing, and the document held open to be written back. */ + /** + * Whether the edit mode is on. The render does not depend on it - a document the core can write + * back always carries its editor - so it only says which mode the page is put into once loaded, + * including after a save loads the written document again. + */ var editable: Boolean = false var password: String? = null diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt b/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt index c2c78796b802..1203db1ef2e8 100644 --- a/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt +++ b/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt @@ -23,25 +23,22 @@ class DocumentSaver( /** * Saves [document] to [target]. * - * @param htmlDiff the edits still only in the page, or null for a "full save" of the file as it - * is on disk. + * @param payload the edits or marks still only in the page, or null for a "full save" of the + * file as it is on disk. */ - fun save(document: LoadedDocument, target: Uri, htmlDiff: String?) { - // only the retranslated file is ours to remove afterwards - the other branch hands back - // the cache file of the document that is still open - var retranslated: File? = null + fun save(document: LoadedDocument, target: Uri, payload: String?) { + // only the edited file is ours to remove afterwards - the other branch hands back the + // cache file of the document that is still open + var edited: File? = null var backup: File? = null var backupIsTheLastCopy = false try { val fileToSave = - if (htmlDiff != null) { - val edited = - coreLoader.retranslate(document.request, document.file, htmlDiff) - ?: throw RuntimeException("retranslate failed") - retranslated = edited - - edited + if (payload != null) { + coreLoader.writeEdits(document.request, document.file, payload)?.also { + edited = it + } ?: throw RuntimeException("writing the edits failed") } else { // "full save" from the main UI checkNotNull(FileCache.getCacheFile(context, document.file.cacheUri)) { @@ -63,7 +60,7 @@ class DocumentSaver( throw e } } finally { - retranslated?.delete() + edited?.delete() // if the rollback did not get the old content back in, this copy is all that is // left of it - leave it in the cache rather than finishing the job diff --git a/app/src/main/java/app/opendocument/droid/background/EditingKind.kt b/app/src/main/java/app/opendocument/droid/background/EditingKind.kt new file mode 100644 index 000000000000..ea6a6df67452 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/EditingKind.kt @@ -0,0 +1,25 @@ +package app.opendocument.droid.background + +/** + * What the user can change in a document, as the core answers it for that document. The page has + * one editor per kind, and each kind saves through its own core call - see `CoreLoader.writeEdits`. + */ +enum class EditingKind { + /** Nothing: the core cannot write this document back. */ + NONE, + + /** A plain text file: its text, and nothing else. */ + TEXT, + + /** A text document or a presentation: its text, and in pro its formatting too. */ + DOCUMENT, + + /** A spreadsheet: one cell at a time. */ + SHEET, + + /** A pdf, which takes marks drawn over it rather than edits. Pro only. */ + ANNOTATION; + + val isEditable: Boolean + get() = this != NONE +} diff --git a/app/src/main/java/app/opendocument/droid/background/FileIdentifier.kt b/app/src/main/java/app/opendocument/droid/background/FileIdentifier.kt index e7901d696f2e..3b6414220d90 100644 --- a/app/src/main/java/app/opendocument/droid/background/FileIdentifier.kt +++ b/app/src/main/java/app/opendocument/droid/background/FileIdentifier.kt @@ -118,9 +118,7 @@ class FileIdentifier(private val crashManager: CrashManager) { /** Whether the core can name the encoding of a file it decided is text. */ private fun hasKnownCharset(file: File): Boolean = try { - val opened = Odr.open(file.absolutePath) - - !opened.isTextFile || opened.asTextFile().charset() != null + CoreLoader.hasKnownEncoding(Odr.open(file.absolutePath)) } catch (e: Throwable) { crashManager.log(e) diff --git a/app/src/main/java/app/opendocument/droid/background/LoadedDocument.kt b/app/src/main/java/app/opendocument/droid/background/LoadedDocument.kt index aae89ca133d6..00f8aa87d540 100644 --- a/app/src/main/java/app/opendocument/droid/background/LoadedDocument.kt +++ b/app/src/main/java/app/opendocument/droid/background/LoadedDocument.kt @@ -11,8 +11,8 @@ import android.os.Parcelable * * [partCuts] runs alongside them, null for every part but a sheet that was cut. * - * [isEditable] and [readsAsDocument] are the core's own answers about this document, never a guess - * from its mime type - see `CoreLoader.isDocumentEditable` and `CoreLoader.readsAsDocument`. + * [editing] and [readsAsDocument] are the core's own answers about this document, never a guess + * from its mime type - see `CoreLoader.editing` and `CoreLoader.readsAsDocument`. */ class LoadedDocument( val request: DocumentRequest, @@ -20,7 +20,7 @@ class LoadedDocument( val partTitles: List, val partUris: List, val partCuts: List, - val isEditable: Boolean, + val editing: EditingKind, val readsAsDocument: Boolean, ) : Parcelable { @@ -32,7 +32,7 @@ class LoadedDocument( parcel.writeList(partTitles) parcel.writeList(partUris) parcel.writeList(partCuts) - ParcelUtil.writeBoolean(parcel, isEditable) + parcel.writeInt(editing.ordinal) ParcelUtil.writeBoolean(parcel, readsAsDocument) } @@ -64,7 +64,7 @@ class LoadedDocument( partTitles, partUris, partCuts, - ParcelUtil.readBoolean(parcel), + EditingKind.entries[parcel.readInt()], ParcelUtil.readBoolean(parcel), ) } diff --git a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt index 8ee1b45fdcf2..7a2f48fca590 100644 --- a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt +++ b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt @@ -1,9 +1,11 @@ package app.opendocument.droid.nonfree +import app.opendocument.droid.BuildConfig + /** - * What this build links, asked by name rather than by flavor. + * What this build links and what it sells, asked by name rather than by flavor. * - * The answer comes from [LINKS_ADS], which the `ads` and `noAds` source sets define next to the + * [withAds] comes from [LINKS_ADS], which the `ads` and `noAds` source sets define next to the * classes it describes, so the flag and the code it stands for cannot disagree. */ object Features { @@ -12,4 +14,11 @@ object Features { * The ad banner, the consent form and the ad removal purchase: lite, and neither of the rest. */ val withAds = LINKS_ADS + + /** + * The edits that reach past one paragraph, formatting, and marking up a pdf: pro and foss. The + * other edits are in every build. Declared per flavor in `app/build.gradle`, because no library + * stands behind it. + */ + val withAdvancedEditing = BuildConfig.ADVANCED_EDITING } diff --git a/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt b/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt index 46b769707439..8874c10608a0 100644 --- a/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt +++ b/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt @@ -1,63 +1,97 @@ package app.opendocument.droid.ui -import android.content.Context import android.view.Menu import android.view.MenuItem -import android.view.inputmethod.InputMethodManager import android.widget.TextView import androidx.appcompat.view.ActionMode import app.opendocument.droid.R +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.ui.activity.DocumentFragment import app.opendocument.droid.ui.activity.MainActivity -@Suppress("DEPRECATION") +/** + * The edit mode: the bar on top with undo, redo and save, and under it the strip of tools the + * document has - see `EditingTools`. A pdf is marked up rather than edited, and has no redo. + */ class EditActionModeCallback( private val activity: MainActivity, private val documentFragment: DocumentFragment, ) : ActionMode.Callback { - private lateinit var imm: InputMethodManager - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + val annotating = documentFragment.editingKind == EditingKind.ANNOTATION + val statusView = TextView(activity) - statusView.setText(R.string.action_edit_banner) + statusView.setText( + if (annotating) R.string.action_annotate_banner else R.string.action_edit_banner + ) mode.customView = statusView mode.menuInflater.inflate(R.menu.edit, menu) - imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + documentFragment.editStateListener = { mode.invalidate() } + documentFragment.setEditing(true) return true } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - documentFragment.reloadUri(true) + menu.findItem(R.id.edit_redo).isVisible = + documentFragment.editingKind != EditingKind.ANNOTATION - imm.toggleSoftInputFromWindow( - activity.window.decorView.rootView.windowToken, - InputMethodManager.SHOW_FORCED, - InputMethodManager.HIDE_IMPLICIT_ONLY, - ) + setEnabled(menu.findItem(R.id.edit_undo), documentFragment.canUndo) + setEnabled(menu.findItem(R.id.edit_redo), documentFragment.canRedo) return true } + /** A disabled action item keeps its icon as it was, so it is dimmed here. */ + private fun setEnabled(item: MenuItem, enabled: Boolean) { + item.isEnabled = enabled + item.icon = item.icon?.mutate()?.also { it.alpha = if (enabled) 255 else DISABLED_ALPHA } + } + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - if (item.itemId != R.id.edit_save) { - return false - } + when (item.itemId) { + R.id.edit_undo -> { + activity.analyticsManager.report("menu_edit_undo") - // OpenDocument.ios' name for this; menu_save is the button on the document itself - activity.analyticsManager.report("menu_edit_save") + documentFragment.undo() + } - documentFragment.prepareSave({ activity.requestSave() }, false) + R.id.edit_redo -> { + activity.analyticsManager.report("menu_edit_redo") + + documentFragment.redo() + } + + R.id.edit_save -> { + // OpenDocument.ios' name for this; menu_save is the button on the document itself + activity.analyticsManager.report("menu_edit_save") + + documentFragment.prepareSave({ activity.requestSave() }, false) + } + + else -> return false + } return true } override fun onDestroyActionMode(mode: ActionMode) { - imm.toggleSoftInputFromWindow(activity.window.decorView.rootView.windowToken, 0, 0) + documentFragment.editStateListener = null + documentFragment.setEditing(false) + + // the page keeps its edits with the mode off, so they are asked about here rather than + // thrown away. not when the document is being closed: that asked already, and the fragment + // is gone by the time the mode is finished + if (documentFragment.isAdded && documentFragment.hasUnsavedEdits()) { + activity.confirmLeavingEdits { documentFragment.discardEdits() } + } + } - documentFragment.reloadUri(false) + private companion object { + /** Material's opacity for a disabled icon, 38%. */ + const val DISABLED_ALPHA = 97 } } 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 78a118486489..e7231ae92de2 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 @@ -15,6 +15,7 @@ import android.text.style.ClickableSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.TextView import android.widget.Toast @@ -29,6 +30,7 @@ import app.opendocument.droid.R import app.opendocument.droid.background.DocumentDarkening import app.opendocument.droid.background.DocumentLoader import app.opendocument.droid.background.DocumentRequest +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.background.IdentifiedFile import app.opendocument.droid.background.LoadedDocument import app.opendocument.droid.background.NightModeSetting @@ -38,14 +40,17 @@ import app.opendocument.droid.background.SheetCut import app.opendocument.droid.nonfree.AnalyticsConstants import app.opendocument.droid.nonfree.AnalyticsManager import app.opendocument.droid.nonfree.CrashManager +import app.opendocument.droid.nonfree.Features 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.EditingTools import app.opendocument.droid.ui.widget.PageView import app.opendocument.droid.ui.widget.ProgressDialogFragment import com.google.android.material.tabs.TabLayout import java.io.FileNotFoundException import java.text.NumberFormat +import org.json.JSONObject class DocumentFragment : Fragment(), DocumentLoader.Listener { @@ -62,8 +67,12 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { private set private lateinit var actions: DocumentActions + private lateinit var editingTools: EditingTools private var bottomInset = 0 + /** Told when [canUndo] or [canRedo] changed, so the edit mode's bar can follow. */ + var editStateListener: (() -> Unit)? = null + /** Folding the actions back up is what back does first, while they are unfolded. */ private val actionsBackCallback = object : OnBackPressedCallback(false) { @@ -112,7 +121,14 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { /** Only ever the document currently on screen. */ var lastDocument: LoadedDocument? = null - var currentHtmlDiff: String? = null + /** What the page handed over for the save in progress: its operations, or its marks. */ + var currentEditPayload: String? = null + + /** Whether the page holds edits or marks no save has written - see [hasUnsavedEdits]. */ + var editsDirty = false + + var canUndo = false + var canRedo = false // loads cannot be canceled once running, so results of abandoned loads // (e.g. user navigated back while the document was still loading) are @@ -177,6 +193,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { this.pageView = pageView pageView.setDocumentFragment(this) + pageView.editingListener = pageEditingListener } catch (t: Throwable) { // crashManager is not set yet: onViewCreated has not run @@ -214,6 +231,9 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } actions.expandedListener = { expanded -> actionsBackCallback.isEnabled = expanded } + editingTools = view.findViewById(R.id.editing_tools) + editingTools.listener = editingToolsListener + // 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) @@ -239,6 +259,10 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { // the page view is a new one, and knows nothing of what the old one was told applyDarkening(lastDocument.file) + // an action mode does not outlive its activity, so neither does the edit mode + state.lastRequest?.editable = false + pageView?.setEditing(lastDocument.editing, false) + restoreTabs(lastDocument) prepareActions(lastDocument) @@ -273,8 +297,9 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { @Suppress("DEPRECATION") state.lastDocument = savedInstanceState.getParcelable(SAVED_KEY_LAST_DOCUMENT) } - if (state.currentHtmlDiff == null) { - state.currentHtmlDiff = savedInstanceState.getString(SAVED_KEY_CURRENT_HTML_DIFF) + if (state.currentEditPayload == null) { + state.currentEditPayload = + savedInstanceState.getString(SAVED_KEY_CURRENT_EDIT_PAYLOAD) } return pageView?.restoreState(savedInstanceState) != null @@ -316,7 +341,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { outState.putParcelable(SAVED_KEY_LAST_REQUEST, state.lastRequest) outState.putParcelable(SAVED_KEY_LAST_FILE, state.lastFile) outState.putParcelable(SAVED_KEY_LAST_DOCUMENT, state.lastDocument) - outState.putString(SAVED_KEY_CURRENT_HTML_DIFF, state.currentHtmlDiff) + outState.putString(SAVED_KEY_CURRENT_EDIT_PAYLOAD, state.currentEditPayload) pageView?.saveState(outState) } @@ -348,6 +373,9 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { showProgress() + // the page that held them is going, and the one replacing it starts with a clean log + setEditState(dirty = false, canUndo = false, canRedo = false) + state.beginLoadIdling() } @@ -370,20 +398,169 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { load(DocumentRequest(uri, persistentUri).apply { this.editable = editable }) } - fun reloadUri(editable: Boolean) { - // closeDocument() removes this fragment and only then finishes the edit mode, whose - // onDestroyActionMode reloads - a load queued here would have nothing left to land in + /** + * Turns the page's edit mode on or off. No render: a document the core can write back carries + * its editor from the start, so this is a switch in the page - see `PageView.setEditing`. + */ + fun setEditing(editing: Boolean) { + // closeDocument() removes this fragment and only then finishes the edit mode if (!isAdded) { return } - val lastRequest = requireLastRequest() - lastRequest.editable = editable + val document = state.lastDocument ?: return + requireLastRequest().editable = editing + + pageView?.setEditing(document.editing, editing) + + showEditingTools(document, editing) - // entering or leaving edit mode is not a new document, and the user is working + if (!editing) { + // a keyboard left up over a document that no longer takes typing + val imm = requireContext().getSystemService(InputMethodManager::class.java) + imm?.hideSoftInputFromWindow(requireView().windowToken, 0) + } + } + + /** + * Drops the edits the page holds by rendering the document again from the copy in the cache, + * which no edit reached. + */ + fun discardEdits() { + if (!isAdded || state.lastDocument == null) { + return + } + + // not a new document, and not one the user went and opened either freshOpenPending = false - reload(lastRequest, requireLastFile()) + reload(requireLastRequest(), requireLastFile()) + } + + /** The strip under the bar, for the kinds of document that have tools to put in it. */ + private fun showEditingTools(document: LoadedDocument, editing: Boolean) { + when { + !editing -> editingTools.hide() + document.editing == EditingKind.DOCUMENT -> + editingTools.showFormatting(locked = !Features.withAdvancedEditing) + document.editing == EditingKind.ANNOTATION -> editingTools.showMarking() + else -> editingTools.hide() + } + } + + fun undo() { + pageView?.undo() + } + + fun redo() { + pageView?.redo() + } + + /** What the page reports while a document is edited. */ + private val pageEditingListener = + object : PageView.EditingListener { + override fun onEditChanged(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) { + setEditState(dirty, canUndo, canRedo) + } + + override fun onEditRefused(reason: String) { + showRefusal(reason) + } + + override fun onSelectionChanged(style: JSONObject) { + editingTools.setSelectionStyle(style) + } + + override fun onMarksChanged(count: Int) { + // a mark is taken back one at a time and never put back, so there is no redo + setEditState(dirty = count > 0, canUndo = count > 0, canRedo = false) + } + + override fun onCellsStale(count: Int) { + if (count == 0) { + return + } + + SnackbarHelper.show( + requireActivity(), + resources.getQuantityString(R.plurals.edit_cells_stale, count, count), + null, + isIndefinite = false, + isError = false, + ) + } + } + + private val editingToolsListener = + object : EditingTools.Listener { + override fun onToggleStyle(property: String) { + analyticsManager.report("edit_format_$property") + + pageView?.toggleStyle(property) + } + + override fun onFormat(style: JSONObject) { + analyticsManager.report("edit_format_" + style.keys().asSequence().joinToString()) + + pageView?.formatStyle(style) + } + + override fun onMarkTool(tool: String, color: Int, recolor: Boolean) { + analyticsManager.report("edit_mark_$tool") + + pageView?.pressMarkTool(tool, color, EditingTools.INK_WIDTH, recolor) { armed -> + editingTools.setArmedTool(armed) + } + } + + override fun onLocked() { + (requireActivity() as MainActivity).offerPro(R.string.pro_offer_formatting) + } + } + + private fun setEditState(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) { + if (!::state.isInitialized) { + return + } + + state.editsDirty = dirty + state.canUndo = canUndo + state.canRedo = canRedo + + editStateListener?.invoke() + } + + val canUndo: Boolean + get() = ::state.isInitialized && state.canUndo + + val canRedo: Boolean + get() = ::state.isInitialized && state.canRedo + + /** + * What an edit the page did not take says. The page gives a reason and an english message for a + * console; the wording a reader sees is ours. + */ + private fun showRefusal(reason: String) { + if (reason == "outOfScope" && !Features.withAdvancedEditing) { + // the one refusal pro answers: an edit that splits or merges a paragraph + (requireActivity() as MainActivity).offerPro(R.string.pro_offer_paragraphs) + + return + } + + val message = + when (reason) { + "newLine" -> R.string.edit_refused_new_line + "formula" -> R.string.edit_refused_formula + "formulaInput" -> R.string.edit_refused_formula_input + "rich" -> R.string.edit_refused_rich + "shapes" -> R.string.edit_refused_shapes + "readOnly" -> R.string.edit_refused_read_only + "range" -> R.string.edit_refused_range + else -> R.string.edit_refused_unsupported + } + + SnackbarHelper.show(requireActivity(), message, null, isIndefinite = false, isError = false) } /** Tells the page whether it may follow the app into night mode - see [DocumentDarkening]. */ @@ -443,21 +620,22 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { /** * Collects whatever the save needs and runs [callback] - exactly once. A full save writes the - * file as it is on disk, so it has no diff to ask the page for. + * file as it is on disk, so it has nothing to ask the page for. */ fun prepareSave(callback: Runnable, fullSave: Boolean) { val pageView = this.pageView + val document = state.lastDocument - if (fullSave || pageView == null) { - state.currentHtmlDiff = null + if (fullSave || pageView == null || document == null) { + state.currentEditPayload = null callback.run() return } - pageView.requestHtml { htmlDiff -> - state.currentHtmlDiff = htmlDiff + pageView.requestEditPayload(document.editing) { payload -> + state.currentEditPayload = payload callback.run() } @@ -476,7 +654,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { return } - documentLoader.save(requireLastDocument(), outFile, state.currentHtmlDiff) + documentLoader.save(requireLastDocument(), outFile, state.currentEditPayload) } private fun unload() { @@ -512,15 +690,24 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { private fun prepareActions(document: LoadedDocument) { // 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 + // legacy binary formats nor the spreadsheets of issue #442 need naming. a pdf is marked up + // rather than edited, and says so - in lite too, where the button offers pro val edit = - if (!document.isEditable) null - else - DocumentActions.Action( - DocumentActions.ACTION_EDIT, - R.string.menu_edit, - R.drawable.ic_edit, - ) + when (document.editing) { + EditingKind.NONE -> null + EditingKind.ANNOTATION -> + DocumentActions.Action( + DocumentActions.ACTION_EDIT, + R.string.menu_annotate, + R.drawable.ic_marker, + ) + else -> + DocumentActions.Action( + DocumentActions.ACTION_EDIT, + R.string.menu_edit, + R.drawable.ic_edit, + ) + } // what the display rows offer is the opposite of what is on screen, so each says what // tapping it does rather than what it is called @@ -693,6 +880,12 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { // before the page is put in below, so it is drawn the way it is going to stay applyDarkening(file) + // and put into the mode it is meant to be in once it has loaded: a save loads the written + // document back in the mode the old one was in. The kind has to be told either way, + // because it decides what leaving the mode does to the page + pageView?.setEditing(document.editing, document.request.editable) + showEditingTools(document, document.request.editable) + analyticsManager.setCurrentScreen(activity, file.mimeType ?: UNKNOWN_FILE_TYPE) // clears lastSelectedTab, so what reloadForMargins put aside is read after it @@ -869,7 +1062,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } override fun onSaveSuccess(target: Uri) { - state.currentHtmlDiff = null + state.currentEditPayload = null SnackbarHelper.show( requireActivity(), @@ -879,11 +1072,12 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { isError = false, ) - loadUri(target, true, true) + // the written document, in the mode the user left the old one in + loadUri(target, true, requireLastRequest().editable) } override fun onSaveError() { - state.currentHtmlDiff = null + state.currentEditPayload = null SnackbarHelper.show( requireActivity(), @@ -1241,9 +1435,15 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { return ::state.isInitialized && state.lastRequest != null } - /** Whether the document is in edit mode, so its changes are still only in the page. */ + /** Whether the document is in edit mode. */ fun isEditing(): Boolean = ::state.isInitialized && state.lastRequest?.editable == true + /** Whether the page holds edits or marks that are only in the page, which leaving loses. */ + fun hasUnsavedEdits(): Boolean = ::state.isInitialized && state.editsDirty + + val editingKind: EditingKind + get() = state.lastDocument?.editing ?: EditingKind.NONE + val lastFileType: String? get() = state.lastFile?.mimeType @@ -1270,7 +1470,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { const val SAVED_KEY_LAST_REQUEST = "LAST_REQUEST" const val SAVED_KEY_LAST_FILE = "LAST_FILE" const val SAVED_KEY_LAST_DOCUMENT = "LAST_DOCUMENT" - const val SAVED_KEY_CURRENT_HTML_DIFF = "CURRENT_HTML_DIFF" + const val SAVED_KEY_CURRENT_EDIT_PAYLOAD = "CURRENT_HTML_DIFF" /** What the analytics screen name is when nothing could name the bytes. */ const val UNKNOWN_FILE_TYPE = "N/A" 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 2ce1312e2b9a..77ebb5c42a08 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 @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModelProvider import app.opendocument.droid.R import app.opendocument.droid.background.CatchAllSetting import app.opendocument.droid.background.DocumentLoader +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.background.NightModeSetting import app.opendocument.droid.background.PaginationSetting import app.opendocument.droid.background.PersistedUriPermissions @@ -620,6 +621,16 @@ class MainActivity : AppCompatActivity() { DocumentActions.ACTION_EDIT -> { analyticsManager.report("menu_edit") + // marking up a pdf is pro's, and the button is there in lite to say so + if ( + documentFragment?.editingKind == EditingKind.ANNOTATION && + !Features.withAdvancedEditing + ) { + offerPro(R.string.pro_offer_markup) + + return + } + documentFragment?.let { fragment -> currentActionMode = startSupportActionMode(EditActionModeCallback(this, fragment)) @@ -733,6 +744,27 @@ class MainActivity : AppCompatActivity() { ) } + /** + * Says that what was just tried is pro's, with a button to the pro listing. Lite is the only + * build that asks: pro and foss have every edit. + */ + fun offerPro(messageRes: Int) { + analyticsManager.report("present_pro_offer") + + SnackbarHelper.show( + this, + messageRes, + R.string.house_ad_cta_get_pro, + { + analyticsManager.report("present_pro_offer_clicked") + + buyAdRemoval() + }, + isIndefinite = false, + isError = false, + ) + } + /** What [buyAdRemoval] is for a build with no ad removal to sell. */ fun openSponsorPage() { analyticsManager.report(AnalyticsConstants.EVENT_ADD_TO_CART) @@ -776,13 +808,13 @@ class MainActivity : AppCompatActivity() { } /** - * Asks before walking away from a document being edited, then runs [leave]. Saving does not - * also leave: it opens the create-document picker, which still needs the page the diff comes - * from. + * Asks before walking away from edits that are only in the page, then runs [leave]. Saving does + * not also leave: it opens the create-document picker, which still needs the page the edits + * come from. */ - private fun confirmLeavingEdits(leave: () -> Unit) { + fun confirmLeavingEdits(leave: () -> Unit) { val documentFragment = this.documentFragment - if (documentFragment == null || !documentFragment.isEditing()) { + if (documentFragment == null || !documentFragment.hasUnsavedEdits()) { leave() return @@ -824,9 +856,9 @@ class MainActivity : AppCompatActivity() { SnackbarHelper.dismiss(this) } - // the fragment goes first: finishing an edit mode reloads the document it acts on, and - // that load would put a progress dialog up over a fragment that is about to be removed. - // reloadUri() is a no-op once it is detached + // the fragment goes first: finishing an edit mode with edits in the page asks about them, + // and that question is about a document that is being closed. EditActionModeCallback only + // asks while the fragment is still added documentFragment?.let { fragment -> supportFragmentManager.beginTransaction().remove(fragment).commitNow() diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt new file mode 100644 index 000000000000..9d1404d93e66 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt @@ -0,0 +1,448 @@ +package app.opendocument.droid.ui.widget + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.HorizontalScrollView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.PopupWindow +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.TooltipCompat +import app.opendocument.droid.R +import org.json.JSONObject + +/** + * The strip of tools under the edit mode's bar: formatting for a text document or a presentation, + * the marking tools for a pdf. OpenDocument.website's viewer has the same strip under its bar. + * + * It only reports taps. What a tool does to the page is the page's, through `PageView`, and which + * tool is on is what the page reports back - [setSelectionStyle] and [setArmedTool]. + */ +class EditingTools(context: Context, attributeSet: AttributeSet?) : + HorizontalScrollView(context, attributeSet) { + + interface Listener { + + /** Flip `bold`, `italic`, `underline` or `strikethrough` on the selection. */ + fun onToggleStyle(property: String) + + /** State [style] on the selection, in the keys `odr.editing.format` takes. */ + fun onFormat(style: JSONObject) + + /** + * A marking tool was pressed, or picked a new [color] where [recolor] - see + * `editing-bridge.js` for what either does to a selection. + */ + fun onMarkTool(tool: String, @ColorInt color: Int, recolor: Boolean) + + /** A tool of pro's was tapped in a build without it. */ + fun onLocked() + } + + var listener: Listener? = null + + private val row: LinearLayout + + /** Whether the tools only offer pro, rather than doing anything - see [showFormatting]. */ + private var locked = false + + private val toggles = mutableMapOf() + private val markTools = mutableMapOf() + private val markColors = mutableMapOf() + + private var textColor = TEXT_COLORS.first().color + private var highlightColor = HIGHLIGHT_COLORS.first().color + + /** What the selection shows, as the page last reported it. */ + private var selectionStyle = JSONObject() + + private var textColorBar: View? = null + private var highlightTool: View? = null + private var sizeTool: TextView? = null + + init { + LayoutInflater.from(context).inflate(R.layout.view_editing_tools, this, true) + + row = findViewById(R.id.editing_tools_row) + + isHorizontalScrollBarEnabled = false + visibility = View.GONE + } + + fun hide() { + visibility = View.GONE + } + + /** + * The formatting tools. [locked] puts pro's badge in front of them, and makes every one of them + * an offer of pro instead of an action. + */ + fun showFormatting(locked: Boolean) { + reset(locked) + + if (locked) { + val badge = newText(R.string.tool_pro_badge) + badge.isSelected = true + row.addView(badge) + } + + addToggle("bold", R.drawable.ic_format_bold, R.string.tool_bold) + addToggle("italic", R.drawable.ic_format_italic, R.string.tool_italic) + addToggle("underline", R.drawable.ic_format_underlined, R.string.tool_underline) + addToggle( + "strikethrough", + R.drawable.ic_format_strikethrough, + R.string.tool_strikethrough, + ) + + val textColorTool = newTool(R.drawable.ic_text_color, R.string.tool_text_color) + textColorBar = barOf(textColorTool).also { paintBar(it, textColor) } + textColorTool.setOnClickListener { + ifUnlocked { listener?.onFormat(JSONObject().put("color", hex(textColor))) } + } + row.addView(textColorTool) + addChevron(R.string.tool_text_color) { anchor -> + showPalette(anchor, TEXT_COLORS) { color -> + textColor = color + textColorBar?.let { paintBar(it, color) } + + listener?.onFormat(JSONObject().put("color", hex(color))) + } + } + + // a split button: the tool turns the highlight on and off, the arrow picks its colour + val highlight = newTool(R.drawable.ic_marker, R.string.tool_highlight) + paintBar(barOf(highlight), highlightColor) + highlight.setOnClickListener { + ifUnlocked { + // isNull is also true of a key the page left out, where the runs disagree + val on = !selectionStyle.isNull("highlight") + + listener?.onFormat( + JSONObject().put("highlight", if (on) JSONObject.NULL else hex(highlightColor)) + ) + } + } + highlightTool = highlight + row.addView(highlight) + addChevron(R.string.tool_highlight) { anchor -> + showPalette(anchor, HIGHLIGHT_COLORS) { color -> + if (color == Color.TRANSPARENT) { + listener?.onFormat(JSONObject().put("highlight", JSONObject.NULL)) + + return@showPalette + } + + highlightColor = color + paintBar(barOf(highlight), color) + + listener?.onFormat(JSONObject().put("highlight", hex(color))) + } + } + + val size = newText(R.string.tool_font_size) + size.contentDescription = context.getString(R.string.tool_font_size) + TooltipCompat.setTooltipText(size, context.getString(R.string.tool_font_size)) + size.setOnClickListener { ifUnlocked { showSizes(size) } } + sizeTool = size + row.addView(size) + + setSelectionStyle(selectionStyle) + + visibility = View.VISIBLE + } + + /** The five marking tools of a pdf, each with a colour of its own. */ + fun showMarking() { + reset(false) + + for (mark in MARKS) { + val color = markColors.getOrPut(mark.tool) { mark.color } + + val tool = newTool(mark.icon, mark.label) + paintBar(barOf(tool), color) + tool.setOnClickListener { + listener?.onMarkTool(mark.tool, markColors.getValue(mark.tool), false) + } + markTools[mark.tool] = tool + row.addView(tool) + + addChevron(mark.label) { anchor -> + showPalette(anchor, MARK_COLORS) { picked -> + markColors[mark.tool] = picked + paintBar(barOf(tool), picked) + + listener?.onMarkTool(mark.tool, picked, true) + } + } + } + + visibility = View.VISIBLE + } + + /** Shows which of the toggles the selection has on, and the size it is set in. */ + fun setSelectionStyle(style: JSONObject) { + selectionStyle = style + + for ((property, view) in toggles) { + view.isSelected = !locked && style.optBoolean(property, false) + } + + highlightTool?.isSelected = !locked && !style.isNull("highlight") + + sizeTool?.text = + style + .optString("size", "") + .removeSuffix("pt") + .takeIf { it.isNotEmpty() && !style.isNull("size") } + ?.let { context.getString(R.string.tool_font_size_points, it) } + ?: context.getString(R.string.tool_font_size) + } + + /** Shows which marking tool is armed, or none. */ + fun setArmedTool(tool: String?) { + for ((name, view) in markTools) { + view.isSelected = name == tool + } + } + + private fun reset(locked: Boolean) { + this.locked = locked + + row.removeAllViews() + toggles.clear() + markTools.clear() + textColorBar = null + highlightTool = null + sizeTool = null + selectionStyle = JSONObject() + + scrollTo(0, 0) + } + + private fun ifUnlocked(action: () -> Unit) { + if (locked) { + listener?.onLocked() + } else { + action() + } + } + + private fun addToggle(property: String, @DrawableRes icon: Int, @StringRes label: Int) { + val tool = newTool(icon, label) + tool.setOnClickListener { ifUnlocked { listener?.onToggleStyle(property) } } + + toggles[property] = tool + row.addView(tool) + } + + private fun addChevron(@StringRes label: Int, open: (View) -> Unit) { + val chevron = + LayoutInflater.from(context).inflate(R.layout.item_editing_tool_chevron, row, false) + + val description = context.getString(R.string.tool_color_of, context.getString(label)) + chevron.contentDescription = description + TooltipCompat.setTooltipText(chevron, description) + + chevron.setOnClickListener { ifUnlocked { open(it) } } + + row.addView(chevron) + } + + private fun newTool(@DrawableRes icon: Int, @StringRes label: Int): View { + val tool = LayoutInflater.from(context).inflate(R.layout.item_editing_tool, row, false) + + tool.findViewById(R.id.editing_tool_icon).setImageResource(icon) + tool.contentDescription = context.getString(label) + + // no label beside it, so the name is what a long press turns up + TooltipCompat.setTooltipText(tool, context.getString(label)) + + return tool + } + + private fun newText(@StringRes text: Int): TextView { + val view = + LayoutInflater.from(context).inflate(R.layout.item_editing_tool_text, row, false) + as TextView + view.setText(text) + + if (text == R.string.tool_pro_badge) { + view.setOnClickListener { listener?.onLocked() } + } + + return view + } + + private fun barOf(tool: View): View = + tool.findViewById(R.id.editing_tool_bar).also { it.visibility = View.VISIBLE } + + private fun paintBar(bar: View, @ColorInt color: Int) { + (bar.background.mutate() as GradientDrawable).setColor(color) + } + + private fun showSizes(anchor: View) { + val popup = PopupMenu(context, anchor) + for ((index, size) in FONT_SIZES.withIndex()) { + popup.menu.add( + 0, + index, + index, + context.getString(R.string.tool_font_size_points, "$size"), + ) + } + popup.setOnMenuItemClickListener { item -> + listener?.onFormat(JSONObject().put("size", "${FONT_SIZES[item.itemId]}pt")) + + true + } + popup.show() + } + + private fun showPalette(anchor: View, colors: List, picked: (Int) -> Unit) { + val content = LayoutInflater.from(context).inflate(R.layout.view_color_palette, null) + val paletteRow: LinearLayout = content.findViewById(R.id.color_palette_row) + + val popup = + PopupWindow( + content, + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + true, + ) + popup.elevation = 8 * resources.displayMetrics.density + popup.setBackgroundDrawable( + GradientDrawable().apply { + setColor(themeColor(com.google.android.material.R.attr.colorSurfaceContainer)) + cornerRadius = 12 * resources.displayMetrics.density + } + ) + + val size = (36 * resources.displayMetrics.density).toInt() + val margin = (4 * resources.displayMetrics.density).toInt() + + for (named in colors) { + val swatch = View(context) + swatch.layoutParams = + LinearLayout.LayoutParams(size, size).apply { setMargins(margin, 0, margin, 0) } + swatch.background = + context.getDrawable(R.drawable.bg_color_swatch)!!.mutate().also { + // no fill at all is the swatch for no highlight + (it as GradientDrawable).setColor(named.color) + } + swatch.contentDescription = context.getString(named.name) + TooltipCompat.setTooltipText(swatch, context.getString(named.name)) + swatch.isClickable = true + swatch.isFocusable = true + swatch.setOnClickListener { + popup.dismiss() + + picked(named.color) + } + + paletteRow.addView(swatch) + } + + popup.showAsDropDown(anchor) + } + + @ColorInt + private fun themeColor(attribute: Int): Int { + val value = android.util.TypedValue() + context.theme.resolveAttribute(attribute, value, true) + + return value.data + } + + private class NamedColor(@param:ColorInt val color: Int, @param:StringRes val name: Int) + + private class Mark( + val tool: String, + @param:DrawableRes val icon: Int, + @param:StringRes val label: Int, + @param:ColorInt val color: Int, + ) + + companion object { + + /** The width of a line the Draw tool makes, in pdf points. */ + const val INK_WIDTH = 2f + + /** `#rrggbb`, the one spelling `odr.editing.format` takes. */ + private fun hex(@ColorInt color: Int) = String.format("#%06x", color and 0xffffff) + + /** Point sizes a document commonly uses. */ + private val FONT_SIZES = listOf(8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48) + + private val TEXT_COLORS = + listOf( + NamedColor(0xff000000.toInt(), R.string.color_black), + NamedColor(0xff757575.toInt(), R.string.color_gray), + NamedColor(0xffe53935.toInt(), R.string.color_red), + NamedColor(0xfffb8c00.toInt(), R.string.color_orange), + NamedColor(0xff43a047.toInt(), R.string.color_green), + NamedColor(0xff1e88e5.toInt(), R.string.color_blue), + NamedColor(0xff8e24aa.toInt(), R.string.color_purple), + ) + + private val HIGHLIGHT_COLORS = + listOf( + NamedColor(0xfffff59d.toInt(), R.string.color_yellow), + NamedColor(0xffc5e1a5.toInt(), R.string.color_green), + NamedColor(0xff90caf9.toInt(), R.string.color_blue), + NamedColor(0xfff48fb1.toInt(), R.string.color_pink), + NamedColor(0xffffcc80.toInt(), R.string.color_orange), + NamedColor(Color.TRANSPARENT, R.string.color_none), + ) + + private val MARK_COLORS = + listOf( + NamedColor(0xffffe633.toInt(), R.string.color_yellow), + NamedColor(0xffe53935.toInt(), R.string.color_red), + NamedColor(0xff43a047.toInt(), R.string.color_green), + NamedColor(0xff1e88e5.toInt(), R.string.color_blue), + NamedColor(0xff000000.toInt(), R.string.color_black), + ) + + /** + * The five kinds of mark a pdf takes, in the annotator's own names, each with the colour it + * starts with: a wash for the highlighter, red for the three lines, blue ink for the pen. + */ + private val MARKS = + listOf( + Mark( + "highlight", + R.drawable.ic_marker, + R.string.tool_mark_highlight, + 0xffffe633.toInt(), + ), + Mark( + "underline", + R.drawable.ic_format_underlined, + R.string.tool_mark_underline, + 0xffe53935.toInt(), + ), + Mark( + "strikeOut", + R.drawable.ic_format_strikethrough, + R.string.tool_mark_strike_out, + 0xffe53935.toInt(), + ), + Mark( + "squiggly", + R.drawable.ic_format_squiggly, + R.string.tool_mark_squiggly, + 0xffe53935.toInt(), + ), + Mark("ink", R.drawable.ic_draw, R.string.tool_mark_draw, 0xff1e88e5.toInt()), + ) + } +} 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 f355c594e3bd..84e29005f32f 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 @@ -20,6 +20,7 @@ import android.webkit.WebViewClient import androidx.annotation.Keep import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.background.FileCache import app.opendocument.droid.background.StreamUtil import app.opendocument.droid.nonfree.CrashManager @@ -27,6 +28,8 @@ import app.opendocument.droid.ui.ParagraphListener import app.opendocument.droid.ui.activity.DocumentFragment import java.io.ByteArrayInputStream import java.io.IOException +import org.json.JSONObject +import org.json.JSONTokener /** * The WebView the documents are displayed in, plus the javascript bridge the page talks back on. @@ -42,7 +45,16 @@ constructor(context: Context, attributeSet: AttributeSet?) : private lateinit var documentFragment: DocumentFragment private lateinit var crashManager: CrashManager - private var htmlCallback: HtmlCallback? = null + /** Told what the page's editor reports, on the main thread - see `editing-bridge.js`. */ + var editingListener: EditingListener? = null + + /** What [setEditing] was last told, which every page loaded after it is put into as well. */ + private var editingKind = EditingKind.NONE + private var isEditing = false + + private val editingBridgeScript: String by lazy { + context.assets.open(EDITING_BRIDGE_ASSET).bufferedReader().use { it.readText() } + } /** * Progress 100 reported before the page commits leaves it blank @@ -92,6 +104,15 @@ constructor(context: Context, attributeSet: AttributeSet?) : restorePendingScroll(0) + // a sheet loads a page per tab, and each one is a page of its own to wire up + if (isOwnContent(url)) { + evaluateJavascript(editingBridgeScript, null) + + if (isEditing) { + applyEditing() + } + } + buggyWebViewHandler.postDelayed( { // [url] and not whatever is loaded now: this callback can arrive after @@ -436,16 +457,142 @@ constructor(context: Context, attributeSet: AttributeSet?) : } } - fun requestHtml(callback: HtmlCallback) { - this.htmlCallback = callback + /** + * Turns the page's edit mode on or off. A pdf has no mode: its tools arm themselves, so leaving + * only disarms whatever tool is armed. + */ + fun setEditing(kind: EditingKind, editing: Boolean) { + editingKind = kind + isEditing = editing + + applyEditing() + } + + private fun applyEditing() { + evaluateJavascript( + when { + editingKind == EditingKind.ANNOTATION -> + if (isEditing) "void 0" + else "window.odr && odr.androidEditing && odr.androidEditing.disarm()" + isEditing -> "window.odr && odr.editing && odr.editing.enable()" + else -> "window.odr && odr.editing && odr.editing.disable()" + }, + null, + ) + } + + fun undo() { + evaluateJavascript( + if (editingKind == EditingKind.ANNOTATION) + "window.odr && odr.androidEditing && odr.androidEditing.undoMark()" + else "window.odr && odr.editing && odr.editing.undo()", + null, + ) + } + + fun redo() { + evaluateJavascript("window.odr && odr.editing && odr.editing.redo()", null) + } + + /** Flips `bold`, `italic`, `underline` or `strikethrough` on the selection. */ + fun toggleStyle(property: String) { + evaluateJavascript("odr.editing.toggle(${JSONObject.quote(property)})", null) + } + + /** States [style] on the selection, in the keys `odr.editing.format` takes. */ + fun formatStyle(style: JSONObject) { + evaluateJavascript("odr.editing.format($style)", null) + } + + /** + * A marking tool was pressed, or [recolor] given a new colour. [callback] gets the tool left + * armed, or null. + */ + fun pressMarkTool( + tool: String, + color: Int, + width: Float, + recolor: Boolean, + callback: (String?) -> Unit, + ) { + val rgb = + "[${android.graphics.Color.red(color) / 255f}," + + "${android.graphics.Color.green(color) / 255f}," + + "${android.graphics.Color.blue(color) / 255f}]" + val method = if (recolor) "recolor" else "tool" + + evaluateJavascript( + "window.odr && odr.androidEditing ? " + + "odr.androidEditing.$method(${JSONObject.quote(tool)}, $rgb, $width) : null" + ) { + callback(decodeString(it)) + } + } + + /** + * What a save hands the core: the page's operation log, or for a pdf the marks drawn over it. + * Null where the page could not say. + */ + fun requestEditPayload(kind: EditingKind, callback: (String?) -> Unit) { + val expression = + if (kind == EditingKind.ANNOTATION) { + "window.odr && odr.annotation ? odr.annotation.getAnnotations() : null" + } else { + "window.odr && odr.editing ? odr.editing.getOperations() : null" + } - loadUrl("${JAVASCRIPT_SCHEME}window.$BRIDGE_NAME.sendHtml(odr.generateDiff());") + evaluateJavascript("(function(){return $expression;})()") { callback(decodeString(it)) } + } + + /** A string evaluateJavascript answered with, which arrives as a json literal. */ + private fun decodeString(result: String?): String? = + try { + JSONTokener(result ?: "null").nextValue() as? String + } catch (e: Exception) { + crashManager.log(e) + + null + } + + // called by editing-bridge.js on the javabridge thread, so each posts to the main one + + @JavascriptInterface + @Keep + fun editChanged(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) { + post { editingListener?.onEditChanged(dirty, canUndo, canRedo) } } @JavascriptInterface @Keep - fun sendHtml(htmlDiff: String) { - htmlCallback?.onHtml(htmlDiff) + fun editRefused(reason: String) { + post { editingListener?.onEditRefused(reason) } + } + + @JavascriptInterface + @Keep + fun selectionChanged(style: String) { + val parsed = + try { + JSONObject(style) + } catch (e: Exception) { + crashManager.log(e) + + return + } + + post { editingListener?.onSelectionChanged(parsed) } + } + + @JavascriptInterface + @Keep + fun marksChanged(count: Int) { + post { editingListener?.onMarksChanged(count) } + } + + @JavascriptInterface + @Keep + fun cellsStale(count: Int) { + post { editingListener?.onCellsStale(count) } } @JavascriptInterface @@ -490,15 +637,29 @@ constructor(context: Context, attributeSet: AttributeSet?) : paragraphListener?.end() } - fun interface HtmlCallback { + interface EditingListener { + + fun onEditChanged(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) + + /** [reason] is the page's name for it, such as `outOfScope` or `range`. */ + fun onEditRefused(reason: String) - fun onHtml(htmlDiff: String) + /** What the selection shows, a key per property the runs under it agree on. */ + fun onSelectionChanged(style: JSONObject) + + /** How many marks the pdf holds that no save has written. */ + fun onMarksChanged(count: Int) + + /** How many formula cells an edit left showing an old result. */ + fun onCellsStale(count: Int) } private companion object { const val BRIDGE_NAME = "paragraphListener" + const val EDITING_BRIDGE_ASSET = "editing-bridge.js" + const val JAVASCRIPT_SCHEME = "javascript:" /** Where CoreLoader publishes a translated document. */ diff --git a/app/src/main/res/drawable/bg_color_bar.xml b/app/src/main/res/drawable/bg_color_bar.xml new file mode 100644 index 000000000000..7853c4fa3335 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_bar.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_color_swatch.xml b/app/src/main/res/drawable/bg_color_swatch.xml new file mode 100644 index 000000000000..c6420eea6527 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_swatch.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_editing_tool.xml b/app/src/main/res/drawable/bg_editing_tool.xml new file mode 100644 index 000000000000..60402c01c914 --- /dev/null +++ b/app/src/main/res/drawable/bg_editing_tool.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_down.xml b/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 000000000000..ce583469c11f --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_draw.xml b/app/src/main/res/drawable/ic_draw.xml new file mode 100644 index 000000000000..1a5c1e6be3b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_draw.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_bold.xml b/app/src/main/res/drawable/ic_format_bold.xml new file mode 100644 index 000000000000..25e5865c1c4d --- /dev/null +++ b/app/src/main/res/drawable/ic_format_bold.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_italic.xml b/app/src/main/res/drawable/ic_format_italic.xml new file mode 100644 index 000000000000..c854767416fc --- /dev/null +++ b/app/src/main/res/drawable/ic_format_italic.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_squiggly.xml b/app/src/main/res/drawable/ic_format_squiggly.xml new file mode 100644 index 000000000000..fcdeb60f914d --- /dev/null +++ b/app/src/main/res/drawable/ic_format_squiggly.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_format_strikethrough.xml b/app/src/main/res/drawable/ic_format_strikethrough.xml new file mode 100644 index 000000000000..df6860c9e25d --- /dev/null +++ b/app/src/main/res/drawable/ic_format_strikethrough.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_underlined.xml b/app/src/main/res/drawable/ic_format_underlined.xml new file mode 100644 index 000000000000..e97ab8b32c95 --- /dev/null +++ b/app/src/main/res/drawable/ic_format_underlined.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_marker.xml b/app/src/main/res/drawable/ic_marker.xml new file mode 100644 index 000000000000..233fb5008a0f --- /dev/null +++ b/app/src/main/res/drawable/ic_marker.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_redo.xml b/app/src/main/res/drawable/ic_redo.xml new file mode 100644 index 000000000000..4e218168e2a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_redo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_text_color.xml b/app/src/main/res/drawable/ic_text_color.xml new file mode 100644 index 000000000000..123c59949948 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_color.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_undo.xml b/app/src/main/res/drawable/ic_undo.xml new file mode 100644 index 000000000000..4ce4108809f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/fragment_document.xml b/app/src/main/res/layout/fragment_document.xml index 6df641e7347a..e1dd8c184ca4 100644 --- a/app/src/main/res/layout/fragment_document.xml +++ b/app/src/main/res/layout/fragment_document.xml @@ -10,6 +10,17 @@ android:layout_height="match_parent" android:orientation="vertical"> + + + + + + + + + + diff --git a/app/src/main/res/layout/item_editing_tool_chevron.xml b/app/src/main/res/layout/item_editing_tool_chevron.xml new file mode 100644 index 000000000000..1ad4eb320a26 --- /dev/null +++ b/app/src/main/res/layout/item_editing_tool_chevron.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/item_editing_tool_text.xml b/app/src/main/res/layout/item_editing_tool_text.xml new file mode 100644 index 000000000000..6f094f08e23c --- /dev/null +++ b/app/src/main/res/layout/item_editing_tool_text.xml @@ -0,0 +1,15 @@ + + + diff --git a/app/src/main/res/layout/view_color_palette.xml b/app/src/main/res/layout/view_color_palette.xml new file mode 100644 index 000000000000..2e947c8269c8 --- /dev/null +++ b/app/src/main/res/layout/view_color_palette.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/layout/view_editing_tools.xml b/app/src/main/res/layout/view_editing_tools.xml new file mode 100644 index 000000000000..f60791647a91 --- /dev/null +++ b/app/src/main/res/layout/view_editing_tools.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/menu/edit.xml b/app/src/main/res/menu/edit.xml index a590a94c509d..b0d16bf6d80f 100644 --- a/app/src/main/res/menu/edit.xml +++ b/app/src/main/res/menu/edit.xml @@ -1,11 +1,27 @@ + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13c3b0850c12..56c720167c46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -97,6 +97,56 @@ support@opendocument.app + + Mark up PDF + Select text, then a tool, to mark it + Undo + Redo + Bold + Italic + Underline + Strikethrough + Text colour + Highlight + Font size + + %1$s colour + + %1$s pt + Highlight + Underline + Strike out + Squiggly underline + Draw + Black + Grey + Red + Orange + Yellow + Green + Blue + Purple + Pink + No highlight + + Pro + Formatting text is part of Pro. + Starting or joining paragraphs is part of Pro. The free app edits the text inside one paragraph. + Marking up PDFs is part of Pro. + + A line break inside a paragraph cannot be saved. Press Enter for a new paragraph. + That cell holds a formula, which stays as it is. + Typing a formula is not supported yet. A number or some text is. + That cell holds more than plain text, so it stays as it is. + That cell holds a drawing, so it stays as it is. + This document cannot be edited. + An edit cannot reach over a picture or a table. + That kind of edit is not supported. + + %d formula cell shows a result your edit made out of date. The saved file keeps the formula, and a spreadsheet app computes it again. + %d formula cells show results your edit made out of date. The saved file keeps the formulas, and a spreadsheet app computes them again. + + OK Cancel diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6240a4f7fd0b..ba0fd1c84436 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ googleJavaFormat = "1.35.0" ktfmt = "0.64" # odrcore's JNI bindings, java and native in one AAR, published from OpenDocument.core -odrCore = "6.13.0" +odrCore = "7.0.0" androidxAnnotation = "1.10.0" androidxAppcompat = "1.8.0" From e59dd46252f7c1f985c09f16afce0d02bdb4e036 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 18 Sep 2026 21:15:14 +0200 Subject: [PATCH 2/7] Draw the lite line where the feature gate plan draws it The plan (OpenDocument.core offline/feature-gate-plan.md) keeps free what the free app shipped: an edit inside one paragraph of a text document or a presentation. Sheet cells, plain text files and pdf marks are Pro's, with paragraphs and formatting. - The flag is ADVANCED_EDITING in Linked.kt, beside LINKS_ADS, as OpenDocument.ios has it. It is no longer a buildConfigField. Features.offersEditing is the one list of what an edition edits. - In lite, the Edit button over a sheet, a plain text file or a pdf offers Pro. Such a document is rendered without the editor. - The offer is a dialog with "Get Pro" and "Not now". A refused edit that reaches past one paragraph raises it once per edit. - The new strings are translated into the 19 locales. - The instrumented tests check each edition's side of the gate, and the README says why the F-Droid build has what free lacks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01F6AQY2k86AaPq12nxBfN7A --- CLAUDE.md | 15 ++-- README.md | 11 +++ app/build.gradle | 9 --- .../app/opendocument/droid/nonfree/Linked.kt | 3 + .../droid/test/MainActivityTests.kt | 70 ++++++++++++++++++- .../droid/background/CoreLoader.kt | 11 ++- .../opendocument/droid/nonfree/Features.kt | 21 ++++-- .../droid/ui/activity/DocumentFragment.kt | 20 ++++-- .../droid/ui/activity/MainActivity.kt | 37 +++++----- app/src/main/res/values-ca/strings.xml | 48 +++++++++++++ app/src/main/res/values-cs/strings.xml | 50 +++++++++++++ app/src/main/res/values-da/strings.xml | 48 +++++++++++++ app/src/main/res/values-de/strings.xml | 48 +++++++++++++ app/src/main/res/values-es/strings.xml | 48 +++++++++++++ app/src/main/res/values-et/strings.xml | 48 +++++++++++++ app/src/main/res/values-fr/strings.xml | 48 +++++++++++++ app/src/main/res/values-ga/strings.xml | 51 ++++++++++++++ app/src/main/res/values-hi/strings.xml | 48 +++++++++++++ app/src/main/res/values-it/strings.xml | 48 +++++++++++++ app/src/main/res/values-ja/strings.xml | 47 +++++++++++++ app/src/main/res/values-ko/strings.xml | 47 +++++++++++++ app/src/main/res/values-pl/strings.xml | 50 +++++++++++++ app/src/main/res/values-pt/strings.xml | 48 +++++++++++++ app/src/main/res/values-ru/strings.xml | 50 +++++++++++++ app/src/main/res/values-sl/strings.xml | 50 +++++++++++++ app/src/main/res/values-sv/strings.xml | 48 +++++++++++++ app/src/main/res/values-tr/strings.xml | 48 +++++++++++++ app/src/main/res/values-zh/strings.xml | 47 +++++++++++++ app/src/main/res/values/strings.xml | 32 +++++---- .../app/opendocument/droid/nonfree/Linked.kt | 3 + 30 files changed, 1085 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8a715cadc31f..275bcf16712e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,10 +117,12 @@ to the other, which `assembleDebug` catches - it builds all three. Code that has to *ask* reads `Features`, never the flavor name. `Features.withAds` comes from `LINKS_ADS`, which sits in `src/ads` and `src/noAds` next to the classes it stands for, -so the flag cannot end up in a build whose code says otherwise. `Features.withAdvancedEditing` -is what pro is sold on - an edit that splits or merges a paragraph, formatting, marking up a -pdf - and is a `buildConfigField` per flavor, because no library stands behind it: false in -lite, true in pro and foss. Everything else the core can edit is in every build. +so the flag cannot end up in a build whose code says otherwise. `Features.advancedEditing`, +from `ADVANCED_EDITING` in the same two files, is what pro is sold on: lite edits the text of +a document inside one paragraph, and pro and foss take every edit the core does - paragraphs, +formatting, sheet cells, plain text, pdf marks. `Features.offersEditing` is the one list of +it; the Edit button still stands on the core's answer, so in lite it offers pro instead. +`OpenDocument.ios` has the same flag beside its own `LINKS_ADS`. Do not add a `BuildConfig.FLAVOR` comparison back - it was what made `BillingManager` miss foss - and do not name a flag after a behaviour it only implies. The resource bool @@ -320,8 +322,9 @@ rendered with `HtmlConfig.editable`, and the edit button only calls `odr.editing no second render, so the reader stays where they were. The page owns the operation log, undo and the refusals; `editing-bridge.js` (injected by `PageView` on every page load) forwards its callbacks. Lite narrows `HtmlConfig.editingScope` to `PARAGRAPH`, and the page refuses the -rest with `outOfScope`, which `DocumentFragment` answers with the offer of pro. A pdf needs no -scaffolding: every pdf page carries `odr.annotation`. +rest with `outOfScope`, which `DocumentFragment` answers with the offer of pro, once an edit. +A kind of document lite does not edit is rendered without the scaffolding. A pdf needs none: +every pdf page carries `odr.annotation`. **Nothing is held open between the render and the save.** `CoreLoader.writeEdits` opens the cached copy again and applies the page's payload with the call its kind takes - diff --git a/README.md b/README.md index 857172ca4a95..603f1f080bad 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ a sideload carrying an older one neither updates nor complains: install the new uninstall the old one. Nothing carries over - a recent documents list whose uri permissions die with the old package anyway. +## Editions + +Play carries two apps: OpenDocument Reader, free with ads, and OpenDocument Reader Pro, +paid. Both open everything. The free app edits the text of a document inside one paragraph; +Pro also starts and joins paragraphs, formats text, edits spreadsheets and plain text files, +and marks up PDFs. + +The F-Droid build and the apk on the release page are Pro without Play's review sheet: no +ads, and every edit. They are built from this repository by anyone who wants to, so a gate in +them would be one line to change. + ## Translations The app speaks nineteen languages and the Play listing fifteen, and both are written diff --git a/app/build.gradle b/app/build.gradle index 87c67f96af07..82dee9664e71 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,12 +101,7 @@ android { } productFlavors { - // the edits a document takes in one paragraph, a sheet's cells and a plain text file are - // in every flavor; ADVANCED_EDITING adds what reaches past that - a paragraph split or - // merged, formatting, and marking up a pdf - and is what pro is sold on lite { - buildConfigField 'boolean', 'ADVANCED_EDITING', 'false' - if (hasReleaseSigning) { signingConfig = signingConfigs.releaseLite } @@ -115,8 +110,6 @@ android { pro { applicationIdSuffix = ".pro" - buildConfigField 'boolean', 'ADVANCED_EDITING', 'true' - if (hasReleaseSigning) { signingConfig = signingConfigs.releasePro } @@ -127,8 +120,6 @@ android { foss { applicationIdSuffix = ".foss" - buildConfigField 'boolean', 'ADVANCED_EDITING', 'true' - if (hasReleaseSigning) { signingConfig = signingConfigs.releaseLite } diff --git a/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt b/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt index 3c96b4c2c084..3f4a4136fdef 100644 --- a/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt +++ b/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt @@ -2,3 +2,6 @@ package app.opendocument.droid.nonfree /** Read through [Features]. */ internal const val LINKS_ADS = true + +/** Read through [Features]. Lite edits inside a paragraph and sells the rest. */ +internal const val ADVANCED_EDITING = false 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 433121d79109..7705143cc5f5 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt @@ -35,6 +35,7 @@ import androidx.test.runner.lifecycle.Stage import app.opendocument.droid.R import app.opendocument.droid.background.PaginationSetting import app.opendocument.droid.background.ReviewInvitation +import app.opendocument.droid.nonfree.Features import app.opendocument.droid.ui.EditActionModeCallback import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.activity.DocumentFragment @@ -150,12 +151,70 @@ 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. the buttons being up is what - // says the pdf opened - Edit is not, because the core does not write pdf back + // says the pdf opened waitForDocumentActions() - // no unfolding first: every unfolding row is in the hierarchy whether the column is - // open or not, and doesNotExist walks all of it + // a pdf is marked up, not edited. no unfolding first: every unfolding row is in the + // hierarchy whether the column is open or not, and doesNotExist walks all of it onView(withContentDescription(R.string.menu_edit)).check(doesNotExist()) + + // the button is there in every edition: pro marks, lite says what pro would do + onView(withContentDescription(R.string.menu_annotate)).perform(click()) + + if (Features.advancedEditing) { + onView(withContentDescription(R.string.tool_mark_draw)).check(matches(isDisplayed())) + } else { + awaitViewWithText(R.string.pro_offer_title) + onView(withText(R.string.pro_offer_title)).check(matches(isDisplayed())) + } + } + + /** A sheet takes cell edits where the edition offers them, and offers pro where it does not. */ + @Test + fun aSheetIsEditedWhereTheEditionOffersIt() { + respondToOpenDocumentWith(requireTestFile("spreadsheet-test.ods")) + + openDocumentThroughPicker() + waitForDocumentActions() + + // the button stands on the core's answer, whichever the edition + onView(withContentDescription(R.string.menu_edit)).perform(click()) + + val activity = mainActivityActivityTestRule.activity + val pageView = requireNotNull(waitForDocumentFragment(activity, 10000)?.pageView) + + if (Features.advancedEditing) { + Assert.assertTrue( + "the sheet should turn editable", + waitFor(EDIT_MODE_TIMEOUT_MS) { pageAnswers(pageView, "odr.editing.isEnabled()") }, + ) + } else { + awaitViewWithText(R.string.pro_offer_title) + onView(withText(R.string.pro_offer_title)).check(matches(isDisplayed())) + + Assert.assertFalse( + "lite should render a sheet without its editor", + pageAnswers(pageView, "odr.editing.isEditable()"), + ) + } + } + + /** Lite edits a text document inside one paragraph, and the page itself holds it to that. */ + @Test + fun theEditionDecidesHowFarAnEditReaches() { + val activity = mainActivityActivityTestRule.activity + val documentFragment = loadDocument(activity, requireTestFile("test.odt")) + val pageView = requireNotNull(documentFragment.pageView) + + val expected = if (Features.advancedEditing) "document" else "paragraph" + + Assert.assertTrue( + "the page should state the scope $expected", + waitFor(EDIT_MODE_TIMEOUT_MS) { + evaluateJavascript(pageView, "window.odr && odr.editing.scope()") + ?.replace("\"", "") == expected + }, + ) } @Test @@ -779,6 +838,10 @@ class MainActivityTests { return result.get() } + /** Whether [expression] is true in the page; false too where the page did not answer. */ + private fun pageAnswers(pageView: PageView, expression: String): Boolean = + evaluateJavascript(pageView, "!!(window.odr && $expression)")?.replace("\"", "") == "true" + private fun requireTestFile(name: String): File = checkNotNull(testFiles[name]) { "test file was not extracted: $name" } @@ -833,6 +896,7 @@ class MainActivityTests { "password-test.odt", "style-various-1.docx", "corrupt.odt", + "spreadsheet-test.ods", )) { val targetFile = File(testDocumentsDir, filename) copy(testAssetManager.open(filename), targetFile) 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 61f5956e1074..917a6e17ef85 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -144,17 +144,14 @@ class CoreLoader(private val context: Context) { htmlConfig.textDocumentMargin = paging // the scaffolding only: the mode starts off, and odr.editing.enable() is what the edit - // button calls. a pdf needs none of it - every pdf page carries odr.annotation - htmlConfig.editable = - editing == EditingKind.TEXT || - editing == EditingKind.DOCUMENT || - editing == EditingKind.SHEET + // button calls. not where this build does not offer the edit - the markup would buy + // nothing - and a pdf needs none of it: every pdf page carries odr.annotation + htmlConfig.editable = editing != EditingKind.ANNOTATION && Features.offersEditing(editing) // an edit that splits or merges a paragraph, and formatting, are pro's. the page refuses // them in lite with outOfScope, which DocumentFragment answers with the offer htmlConfig.editingScope = - if (Features.withAdvancedEditing) HtmlEditingScope.DOCUMENT - else HtmlEditingScope.PARAGRAPH + if (Features.advancedEditing) HtmlEditingScope.DOCUMENT else HtmlEditingScope.PARAGRAPH // both schemes, each behind prefers-color-scheme, rather than the one it is being read in // now: this is decided while translating, and darkening is turned on and off over the open diff --git a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt index 7a2f48fca590..ce80b0d67516 100644 --- a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt +++ b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt @@ -1,12 +1,12 @@ package app.opendocument.droid.nonfree -import app.opendocument.droid.BuildConfig +import app.opendocument.droid.background.EditingKind /** * What this build links and what it sells, asked by name rather than by flavor. * - * [withAds] comes from [LINKS_ADS], which the `ads` and `noAds` source sets define next to the - * classes it describes, so the flag and the code it stands for cannot disagree. + * Both flags come from `Linked.kt`, which the `ads` and `noAds` source sets define next to the + * classes [withAds] describes, so a flag and the code it stands for cannot disagree. */ object Features { @@ -16,9 +16,16 @@ object Features { val withAds = LINKS_ADS /** - * The edits that reach past one paragraph, formatting, and marking up a pdf: pro and foss. The - * other edits are in every build. Declared per flavor in `app/build.gradle`, because no library - * stands behind it. + * Every edit the core takes: pro and foss. Lite edits the text of a document inside one + * paragraph, and the rest is what pro is sold on. */ - val withAdvancedEditing = BuildConfig.ADVANCED_EDITING + val advancedEditing = ADVANCED_EDITING + + /** + * Whether this build lets the user into the edit mode for [kind]. The core answers whether the + * document can be edited at all; this is the edition's policy on top of it, and the one list of + * editing there is. + */ + fun offersEditing(kind: EditingKind): Boolean = + kind == EditingKind.DOCUMENT || (kind.isEditable && advancedEditing) } 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 e7231ae92de2..197510dee70a 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 @@ -70,6 +70,9 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { private lateinit var editingTools: EditingTools private var bottomInset = 0 + /** Whether lite offered pro during this edit - see [showRefusal]. */ + private var proOfferedThisEdit = false + /** Told when [canUndo] or [canRedo] changed, so the edit mode's bar can follow. */ var editStateListener: (() -> Unit)? = null @@ -411,6 +414,10 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { val document = state.lastDocument ?: return requireLastRequest().editable = editing + if (editing) { + proOfferedThisEdit = false + } + pageView?.setEditing(document.editing, editing) showEditingTools(document, editing) @@ -442,7 +449,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { when { !editing -> editingTools.hide() document.editing == EditingKind.DOCUMENT -> - editingTools.showFormatting(locked = !Features.withAdvancedEditing) + editingTools.showFormatting(locked = !Features.advancedEditing) document.editing == EditingKind.ANNOTATION -> editingTools.showMarking() else -> editingTools.hide() } @@ -541,9 +548,14 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { * console; the wording a reader sees is ours. */ private fun showRefusal(reason: String) { - if (reason == "outOfScope" && !Features.withAdvancedEditing) { - // the one refusal pro answers: an edit that splits or merges a paragraph - (requireActivity() as MainActivity).offerPro(R.string.pro_offer_paragraphs) + if (reason == "outOfScope" && !Features.advancedEditing) { + // the one refusal pro answers. once an edit, so a page of refused line breaks is not a + // dialog each + if (!proOfferedThisEdit) { + proOfferedThisEdit = true + + (requireActivity() as MainActivity).offerPro(R.string.pro_offer_formatting) + } return } 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 77ebb5c42a08..cf1b025163fb 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 @@ -621,12 +621,17 @@ class MainActivity : AppCompatActivity() { DocumentActions.ACTION_EDIT -> { analyticsManager.report("menu_edit") - // marking up a pdf is pro's, and the button is there in lite to say so - if ( - documentFragment?.editingKind == EditingKind.ANNOTATION && - !Features.withAdvancedEditing - ) { - offerPro(R.string.pro_offer_markup) + // the button stands on the core's answer, so in lite it is there over a sheet, a + // plain text file and a pdf too, and says what pro would do with them + val kind = documentFragment?.editingKind ?: return + if (!Features.offersEditing(kind)) { + offerPro( + when (kind) { + EditingKind.ANNOTATION -> R.string.pro_offer_markup + EditingKind.SHEET -> R.string.pro_offer_sheets + else -> R.string.pro_offer_text + } + ) return } @@ -745,24 +750,22 @@ class MainActivity : AppCompatActivity() { } /** - * Says that what was just tried is pro's, with a button to the pro listing. Lite is the only - * build that asks: pro and foss have every edit. + * Says that what was just tried is pro's, and leads to the pro listing. Lite is the only build + * that asks: pro and foss have every edit. */ fun offerPro(messageRes: Int) { analyticsManager.report("present_pro_offer") - SnackbarHelper.show( - this, - messageRes, - R.string.house_ad_cta_get_pro, - { + AlertDialog.Builder(this) + .setTitle(R.string.pro_offer_title) + .setMessage(messageRes) + .setPositiveButton(R.string.house_ad_cta_get_pro) { _, _ -> analyticsManager.report("present_pro_offer_clicked") buyAdRemoval() - }, - isIndefinite = false, - isError = false, - ) + } + .setNegativeButton(R.string.not_now, null) + .show() } /** What [buyAdRemoval] is for a build with no ad removal to sell. */ diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 32e39d024c76..0369b75a177f 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -75,4 +75,52 @@ Canvis sense desar Voleu desar-los ara? Descarta + + Marca el PDF + Seleccioneu text i després una eina per marcar-lo + Desfés + Refés + Negreta + Cursiva + Subratllat + Ratllat + Color del text + Ressalta + Mida del text + Color de %1$s + %1$s pt + Ressalta + Subratlla + Ratlla + Subratllat ondulat + Dibuixa + Negre + Gris + Vermell + Taronja + Groc + Verd + Blau + Lila + Rosa + Sense ressaltat + Pro + Part de Pro + Donar format al text, i afegir o unir paràgrafs, forma part d\'OpenDocument Reader Pro. + Marcar un PDF forma part d\'OpenDocument Reader Pro. + Editar fulls de càlcul forma part d\'OpenDocument Reader Pro. + Editar fitxers de text pla forma part d\'OpenDocument Reader Pro. + Ara no + Un salt de línia dins d\'un paràgraf no es pot desar. Premeu Retorn per a un paràgraf nou. + Aquesta cel·la conté una fórmula i es queda tal com està. + Encara no es pot escriure una fórmula. + Aquesta cel·la conté més que text pla i es queda tal com està. + Aquesta cel·la conté un dibuix i es queda tal com està. + Aquest document no es pot editar. + Una edició no pot abastar una imatge o una taula. + Aquesta edició no és possible aquí. + + %d cel·la amb fórmula mostra un resultat que la vostra edició ha deixat desactualitzat. El fitxer desat conserva la fórmula, i una aplicació de fulls de càlcul la torna a calcular. + %d cel·les amb fórmula mostren resultats que la vostra edició ha deixat desactualitzats. El fitxer desat conserva les fórmules, i una aplicació de fulls de càlcul les torna a calcular. + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e4807bcd3805..f2f457bf7678 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -75,4 +75,54 @@ Neuložené změny Chcete je nyní uložit? Zahodit + + Označit PDF + Vyberte text a pak nástroj, kterým ho označíte + Zpět + Znovu + Tučné + Kurzíva + Podtržené + Přeškrtnuté + Barva textu + Zvýraznit + Velikost textu + Barva: %1$s + %1$s pt + Zvýraznit + Podtrhnout + Přeškrtnout + Vlnité podtržení + Kreslit + Černá + Šedá + Červená + Oranžová + Žlutá + Zelená + Modrá + Fialová + Růžová + Bez zvýraznění + Pro + Součást verze Pro + Formátování textu a přidávání nebo spojování odstavců je součástí OpenDocument Reader Pro. + Označování PDF je součástí OpenDocument Reader Pro. + Úpravy tabulek jsou součástí OpenDocument Reader Pro. + Úpravy prostých textových souborů jsou součástí OpenDocument Reader Pro. + Teď ne + Zalomení řádku uvnitř odstavce nelze uložit. Pro nový odstavec stiskněte Enter. + Tato buňka obsahuje vzorec a zůstane beze změny. + Psaní vzorců zatím není podporováno. + Tato buňka obsahuje víc než prostý text a zůstane beze změny. + Tato buňka obsahuje kresbu a zůstane beze změny. + Tento dokument nelze upravit. + Úprava nemůže zasahovat přes obrázek nebo tabulku. + Tato úprava zde není možná. + + %d buňka se vzorcem ukazuje výsledek, který vaše úprava učinila zastaralým. Uložený soubor vzorec zachová a tabulkový program ho spočítá znovu. + %d buňky se vzorcem ukazují výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + %d buňky se vzorcem ukazují výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + %d buněk se vzorcem ukazuje výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 182e8692cebc..a34f76a60504 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -75,4 +75,52 @@ Ikke-gemte ændringer Vil du gemme dem nu? Kassér + + Markér PDF + Vælg tekst og derefter et værktøj for at markere den + Fortryd + Gentag + Fed + Kursiv + Understreget + Gennemstreget + Tekstfarve + Fremhæv + Tekststørrelse + Farve til %1$s + %1$s pt + Fremhæv + Understreg + Gennemstreg + Bølget understregning + Tegn + Sort + Grå + Rød + Orange + Gul + Grøn + Blå + Lilla + Lyserød + Ingen fremhævning + Pro + En del af Pro + Formatering af tekst og tilføjelse eller sammenlægning af afsnit er en del af OpenDocument Reader Pro. + Markering af PDF-filer er en del af OpenDocument Reader Pro. + Redigering af regneark er en del af OpenDocument Reader Pro. + Redigering af almindelige tekstfiler er en del af OpenDocument Reader Pro. + Ikke nu + Et linjeskift inde i et afsnit kan ikke gemmes. Tryk på Enter for et nyt afsnit. + Cellen indeholder en formel og forbliver, som den er. + Indtastning af formler understøttes endnu ikke. + Cellen indeholder mere end almindelig tekst og forbliver, som den er. + Cellen indeholder en tegning og forbliver, som den er. + Dette dokument kan ikke redigeres. + En redigering kan ikke strække sig over et billede eller en tabel. + Den redigering er ikke mulig her. + + %d formelcelle viser et resultat, som din redigering har gjort forældet. Den gemte fil beholder formlen, og et regnearksprogram beregner den igen. + %d formelceller viser resultater, som din redigering har gjort forældede. Den gemte fil beholder formlerne, og et regnearksprogram beregner dem igen. + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9d4a0439ffaa..d2f1b44c5ff5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -75,4 +75,52 @@ Nicht gespeicherte Änderungen Möchten Sie sie jetzt speichern? Verwerfen + + PDF markieren + Text auswählen, dann ein Werkzeug, um ihn zu markieren + Rückgängig + Wiederholen + Fett + Kursiv + Unterstrichen + Durchgestrichen + Textfarbe + Hervorheben + Textgröße + Farbe für %1$s + %1$s pt + Hervorheben + Unterstreichen + Durchstreichen + Wellenlinie + Zeichnen + Schwarz + Grau + Rot + Orange + Gelb + Grün + Blau + Lila + Rosa + Keine Hervorhebung + Pro + Teil von Pro + Text formatieren sowie Absätze einfügen oder zusammenführen ist Teil von OpenDocument Reader Pro. + PDFs markieren ist Teil von OpenDocument Reader Pro. + Tabellendokumente bearbeiten ist Teil von OpenDocument Reader Pro. + Reine Textdateien bearbeiten ist Teil von OpenDocument Reader Pro. + Nicht jetzt + Ein Zeilenumbruch innerhalb eines Absatzes kann nicht gespeichert werden. Drücken Sie die Eingabetaste für einen neuen Absatz. + Diese Zelle enthält eine Formel und bleibt, wie sie ist. + Formeln eingeben wird noch nicht unterstützt. + Diese Zelle enthält mehr als reinen Text und bleibt, wie sie ist. + Diese Zelle enthält eine Zeichnung und bleibt, wie sie ist. + Dieses Dokument kann nicht bearbeitet werden. + Eine Änderung kann nicht über ein Bild oder eine Tabelle hinausreichen. + Diese Änderung ist hier nicht möglich. + + %d Formelzelle zeigt ein Ergebnis, das durch Ihre Änderung veraltet ist. Die gespeicherte Datei behält die Formel, und eine Tabellenkalkulation berechnet sie neu. + %d Formelzellen zeigen Ergebnisse, die durch Ihre Änderung veraltet sind. Die gespeicherte Datei behält die Formeln, und eine Tabellenkalkulation berechnet sie neu. + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ea5c1fe0bce6..0cd40bad6a10 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -75,4 +75,52 @@ Cambios sin guardar ¿Quiere guardarlos ahora? Descartar + + Marcar PDF + Seleccione texto y luego una herramienta para marcarlo + Deshacer + Rehacer + Negrita + Cursiva + Subrayado + Tachado + Color del texto + Resaltar + Tamaño del texto + Color de %1$s + %1$s pt + Resaltar + Subrayar + Tachar + Subrayado ondulado + Dibujar + Negro + Gris + Rojo + Naranja + Amarillo + Verde + Azul + Morado + Rosa + Sin resaltado + Pro + Parte de Pro + Dar formato al texto, así como añadir o unir párrafos, forma parte de OpenDocument Reader Pro. + Marcar un PDF forma parte de OpenDocument Reader Pro. + Editar hojas de cálculo forma parte de OpenDocument Reader Pro. + Editar archivos de texto sin formato forma parte de OpenDocument Reader Pro. + Ahora no + Un salto de línea dentro de un párrafo no se puede guardar. Pulse Intro para crear un párrafo nuevo. + Esa celda contiene una fórmula y se queda como está. + Todavía no se admite escribir fórmulas. + Esa celda contiene más que texto sin formato y se queda como está. + Esa celda contiene un dibujo y se queda como está. + Este documento no se puede editar. + Una edición no puede abarcar una imagen o una tabla. + Esa edición no es posible aquí. + + %d celda con fórmula muestra un resultado que su edición dejó desactualizado. El archivo guardado conserva la fórmula y una aplicación de hojas de cálculo la vuelve a calcular. + %d celdas con fórmula muestran resultados que su edición dejó desactualizados. El archivo guardado conserva las fórmulas y una aplicación de hojas de cálculo las vuelve a calcular. + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9666c69d859e..6f2a8e447c75 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -75,4 +75,52 @@ Salvestamata muudatused Kas soovid need nüüd salvestada? Loobu + + Märgista PDF + Vali tekst ja siis tööriist, et see märgistada + Võta tagasi + Tee uuesti + Paks + Kaldkiri + Allajoonitud + Läbikriipsutatud + Teksti värv + Esiletõst + Teksti suurus + %1$s: värv + %1$s pt + Esiletõst + Allajoonimine + Läbikriipsutus + Lainjas allajoonimine + Joonista + Must + Hall + Punane + Oranž + Kollane + Roheline + Sinine + Lilla + Roosa + Esiletõstuta + Pro + Osa Pro-versioonist + Teksti vormindamine ning lõikude lisamine või ühendamine on osa rakendusest OpenDocument Reader Pro. + PDF-i märgistamine on osa rakendusest OpenDocument Reader Pro. + Arvutustabelite muutmine on osa rakendusest OpenDocument Reader Pro. + Lihttekstifailide muutmine on osa rakendusest OpenDocument Reader Pro. + Mitte praegu + Reavahetust lõigu sees ei saa salvestada. Uue lõigu jaoks vajuta Enter. + Selles lahtris on valem ja see jääb nii, nagu on. + Valemite sisestamist veel ei toetata. + Selles lahtris on rohkem kui lihttekst ja see jääb nii, nagu on. + Selles lahtris on joonis ja see jääb nii, nagu on. + Seda dokumenti ei saa muuta. + Muudatus ei saa ulatuda üle pildi ega tabeli. + See muudatus pole siin võimalik. + + %d valemiga lahter näitab tulemust, mille sinu muudatus aegunuks muutis. Salvestatud fail säilitab valemi ja arvutustabelirakendus arvutab selle uuesti. + %d valemiga lahtrit näitavad tulemusi, mille sinu muudatus aegunuks muutis. Salvestatud fail säilitab valemid ja arvutustabelirakendus arvutab need uuesti. + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 61f625ee39cf..54a9eaec52ec 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -75,4 +75,52 @@ Modifications non enregistrées Voulez-vous les enregistrer maintenant ? Ne pas enregistrer + + Annoter le PDF + Sélectionnez du texte, puis un outil, pour le marquer + Annuler + Rétablir + Gras + Italique + Souligné + Barré + Couleur du texte + Surligner + Taille du texte + Couleur : %1$s + %1$s pt + Surligner + Souligner + Barrer + Soulignement ondulé + Dessiner + Noir + Gris + Rouge + Orange + Jaune + Vert + Bleu + Violet + Rose + Aucun surlignage + Pro + Inclus dans Pro + La mise en forme du texte, ainsi que l\'ajout ou la fusion de paragraphes, fait partie d\'OpenDocument Reader Pro. + L\'annotation des PDF fait partie d\'OpenDocument Reader Pro. + La modification des feuilles de calcul fait partie d\'OpenDocument Reader Pro. + La modification des fichiers texte brut fait partie d\'OpenDocument Reader Pro. + Pas maintenant + Un saut de ligne à l\'intérieur d\'un paragraphe ne peut pas être enregistré. Appuyez sur Entrée pour créer un nouveau paragraphe. + Cette cellule contient une formule et reste telle quelle. + La saisie de formules n\'est pas encore prise en charge. + Cette cellule contient plus que du texte brut et reste telle quelle. + Cette cellule contient un dessin et reste telle quelle. + Ce document ne peut pas être modifié. + Une modification ne peut pas s\'étendre sur une image ou un tableau. + Cette modification n\'est pas possible ici. + + %d cellule de formule affiche un résultat que votre modification a rendu obsolète. Le fichier enregistré conserve la formule, et un tableur la recalcule. + %d cellules de formule affichent des résultats que votre modification a rendus obsolètes. Le fichier enregistré conserve les formules, et un tableur les recalcule. + diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index eb834b4c1f64..734023d18776 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -75,4 +75,55 @@ Athruithe gan sábháil Ar mhaith leat iad a shábháil anois? Ná sábháil + + Marcáil an PDF + Roghnaigh téacs, ansin uirlis, chun é a mharcáil + Cealaigh + Athdhéan + Trom + Iodálach + Líne faoi + Líne tríd + Dath an téacs + Aibhsigh + Méid an téacs + Dath: %1$s + %1$s pt + Aibhsigh + Cuir líne faoi + Cuir líne tríd + Líne chasta faoi + Tarraing + Dubh + Liath + Dearg + Oráiste + Buí + Glas + Gorm + Corcra + Bándearg + Gan aibhsiú + Pro + Cuid de Pro + Is cuid de OpenDocument Reader Pro é téacs a fhormáidiú, agus míreanna a chur leis nó a chumasc. + Is cuid de OpenDocument Reader Pro é PDF a mharcáil. + Is cuid de OpenDocument Reader Pro é scarbhileoga a chur in eagar. + Is cuid de OpenDocument Reader Pro é comhaid ghnáth-théacs a chur in eagar. + Ní anois + Ní féidir briseadh líne laistigh de mhír a shábháil. Brúigh Enter le haghaidh míre nua. + Tá foirmle sa chill sin agus fanfaidh sí mar atá. + Ní thacaítear le foirmle a chlóscríobh fós. + Tá níos mó ná gnáth-théacs sa chill sin agus fanfaidh sí mar atá. + Tá líníocht sa chill sin agus fanfaidh sí mar atá. + Ní féidir an cháipéis seo a chur in eagar. + Ní féidir le heagarthóireacht dul thar phictiúr ná thar tábla. + Níl an eagarthóireacht sin indéanta anseo. + + Taispeánann %d chill fhoirmle toradh atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte an fhoirmle, agus ríomhann aip scarbhileog arís í. + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index c5d8934ad221..6b518e11ec63 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -75,4 +75,52 @@ सहेजे नहीं गए बदलाव क्या आप उन्हें अभी सहेजना चाहते हैं? छोड़ दें + + PDF चिह्नित करें + चिह्नित करने के लिए टेक्स्ट चुनें, फिर कोई टूल + पूर्ववत करें + फिर से करें + बोल्ड + इटैलिक + रेखांकित + काटा हुआ + टेक्स्ट का रंग + हाइलाइट + टेक्स्ट का आकार + %1$s का रंग + %1$s pt + हाइलाइट + रेखांकन + काटें + लहरदार रेखांकन + ड्रॉ करें + काला + धूसर + लाल + नारंगी + पीला + हरा + नीला + बैंगनी + गुलाबी + कोई हाइलाइट नहीं + Pro + Pro का हिस्सा + टेक्स्ट को फ़ॉर्मैट करना और अनुच्छेद जोड़ना या मिलाना OpenDocument Reader Pro का हिस्सा है। + PDF चिह्नित करना OpenDocument Reader Pro का हिस्सा है। + स्प्रेडशीट संपादित करना OpenDocument Reader Pro का हिस्सा है। + सादी टेक्स्ट फ़ाइलें संपादित करना OpenDocument Reader Pro का हिस्सा है। + अभी नहीं + अनुच्छेद के भीतर लाइन ब्रेक सहेजा नहीं जा सकता। नए अनुच्छेद के लिए Enter दबाएँ। + इस सेल में एक सूत्र है, इसलिए यह जैसा है वैसा ही रहेगा। + सूत्र टाइप करना अभी समर्थित नहीं है। + इस सेल में सादे टेक्स्ट से अधिक है, इसलिए यह जैसा है वैसा ही रहेगा। + इस सेल में एक ड्रॉइंग है, इसलिए यह जैसा है वैसा ही रहेगा। + यह दस्तावेज़ संपादित नहीं किया जा सकता। + कोई संपादन किसी चित्र या तालिका के पार नहीं जा सकता। + यह संपादन यहाँ संभव नहीं है। + + %d सूत्र वाला सेल ऐसा परिणाम दिखा रहा है जो आपके संपादन से पुराना हो गया है। सहेजी गई फ़ाइल सूत्र को रखती है, और स्प्रेडशीट ऐप उसे फिर से गणना करता है। + %d सूत्र वाले सेल ऐसे परिणाम दिखा रहे हैं जो आपके संपादन से पुराने हो गए हैं। सहेजी गई फ़ाइल सूत्रों को रखती है, और स्प्रेडशीट ऐप उन्हें फिर से गणना करता है। + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a91a318f0d31..ba2e5fb56cb4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -75,4 +75,52 @@ Modifiche non salvate Vuoi salvarle ora? Ignora + + Annota PDF + Seleziona il testo, poi uno strumento, per evidenziarlo + Annulla + Ripeti + Grassetto + Corsivo + Sottolineato + Barrato + Colore del testo + Evidenzia + Dimensione del testo + Colore di %1$s + %1$s pt + Evidenzia + Sottolinea + Barra + Sottolineatura ondulata + Disegna + Nero + Grigio + Rosso + Arancione + Giallo + Verde + Blu + Viola + Rosa + Nessuna evidenziazione + Pro + Parte di Pro + Formattare il testo e aggiungere o unire paragrafi fa parte di OpenDocument Reader Pro. + Annotare un PDF fa parte di OpenDocument Reader Pro. + Modificare i fogli di calcolo fa parte di OpenDocument Reader Pro. + Modificare i file di testo semplice fa parte di OpenDocument Reader Pro. + Non ora + Un\'interruzione di riga all\'interno di un paragrafo non può essere salvata. Premi Invio per un nuovo paragrafo. + Questa cella contiene una formula e resta così com\'è. + Digitare una formula non è ancora supportato. + Questa cella contiene più del semplice testo e resta così com\'è. + Questa cella contiene un disegno e resta così com\'è. + Questo documento non può essere modificato. + Una modifica non può estendersi su un\'immagine o una tabella. + Questa modifica non è possibile qui. + + %d cella con formula mostra un risultato reso obsoleto dalla tua modifica. Il file salvato mantiene la formula e un\'app per fogli di calcolo la ricalcola. + %d celle con formula mostrano risultati resi obsoleti dalla tua modifica. Il file salvato mantiene le formule e un\'app per fogli di calcolo le ricalcola. + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 8aa8d7998fd2..d1ae2c25b702 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -75,4 +75,51 @@ 保存されていない変更 今すぐ保存しますか? 破棄 + + PDF に書き込む + テキストを選択してからツールを選ぶと、マークできます + 元に戻す + やり直す + 太字 + 斜体 + 下線 + 取り消し線 + 文字の色 + ハイライト + 文字サイズ + %1$sの色 + %1$s pt + ハイライト + 下線 + 取り消し線 + 波線 + 描画 + + グレー + + オレンジ + + + + + ピンク + ハイライトなし + Pro + Pro の機能 + テキストの書式設定と段落の追加・結合は OpenDocument Reader Pro の機能です。 + PDF への書き込みは OpenDocument Reader Pro の機能です。 + スプレッドシートの編集は OpenDocument Reader Pro の機能です。 + プレーンテキストファイルの編集は OpenDocument Reader Pro の機能です。 + 今はしない + 段落内の改行は保存できません。新しい段落にするには Enter を押してください。 + このセルには数式があるため、そのままになります。 + 数式の入力にはまだ対応していません。 + このセルにはプレーンテキスト以外の内容があるため、そのままになります。 + このセルには図形があるため、そのままになります。 + このドキュメントは編集できません。 + 画像や表をまたいで編集することはできません。 + ここではその編集はできません。 + + %d 個の数式セルに、編集によって古くなった結果が表示されています。保存したファイルには数式が残り、表計算アプリで再計算されます。 + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2ac53a6585c4..c24ef278c3e3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -75,4 +75,51 @@ 저장하지 않은 변경 사항 지금 저장하시겠습니까? 저장 안 함 + + PDF 표시하기 + 텍스트를 선택한 다음 도구를 선택해 표시하세요 + 실행 취소 + 다시 실행 + 굵게 + 기울임꼴 + 밑줄 + 취소선 + 글자 색 + 강조 표시 + 글자 크기 + %1$s 색상 + %1$s pt + 강조 표시 + 밑줄 + 취소선 + 물결 밑줄 + 그리기 + 검정 + 회색 + 빨강 + 주황 + 노랑 + 초록 + 파랑 + 보라 + 분홍 + 강조 표시 없음 + Pro + Pro 기능 + 텍스트 서식 지정과 단락 추가 또는 병합은 OpenDocument Reader Pro 기능입니다. + PDF 표시는 OpenDocument Reader Pro 기능입니다. + 스프레드시트 편집은 OpenDocument Reader Pro 기능입니다. + 일반 텍스트 파일 편집은 OpenDocument Reader Pro 기능입니다. + 나중에 + 단락 안의 줄 바꿈은 저장할 수 없습니다. 새 단락을 만들려면 Enter를 누르세요. + 이 셀에는 수식이 있어 그대로 유지됩니다. + 수식 입력은 아직 지원되지 않습니다. + 이 셀에는 일반 텍스트 이상의 내용이 있어 그대로 유지됩니다. + 이 셀에는 그림이 있어 그대로 유지됩니다. + 이 문서는 편집할 수 없습니다. + 편집 범위에 그림이나 표를 포함할 수 없습니다. + 여기서는 그렇게 편집할 수 없습니다. + + 수식 셀 %d개에 편집으로 인해 오래된 결과가 표시됩니다. 저장된 파일에는 수식이 유지되며, 스프레드시트 앱에서 다시 계산합니다. + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 45e7d1c45859..37304efe9956 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -75,4 +75,54 @@ Niezapisane zmiany Czy chcesz je teraz zapisać? Odrzuć + + Oznacz PDF + Zaznacz tekst, a potem wybierz narzędzie, aby go oznaczyć + Cofnij + Ponów + Pogrubienie + Kursywa + Podkreślenie + Przekreślenie + Kolor tekstu + Wyróżnienie + Rozmiar tekstu + Kolor: %1$s + %1$s pt + Wyróżnienie + Podkreślenie + Przekreślenie + Podkreślenie falowane + Rysowanie + Czarny + Szary + Czerwony + Pomarańczowy + Żółty + Zielony + Niebieski + Fioletowy + Różowy + Bez wyróżnienia + Pro + Część wersji Pro + Formatowanie tekstu oraz dodawanie i łączenie akapitów to część OpenDocument Reader Pro. + Oznaczanie plików PDF to część OpenDocument Reader Pro. + Edycja arkuszy kalkulacyjnych to część OpenDocument Reader Pro. + Edycja zwykłych plików tekstowych to część OpenDocument Reader Pro. + Nie teraz + Podziału wiersza wewnątrz akapitu nie można zapisać. Naciśnij Enter, aby utworzyć nowy akapit. + Ta komórka zawiera formułę i pozostaje bez zmian. + Wpisywanie formuł nie jest jeszcze obsługiwane. + Ta komórka zawiera więcej niż zwykły tekst i pozostaje bez zmian. + Ta komórka zawiera rysunek i pozostaje bez zmian. + Tego dokumentu nie można edytować. + Edycja nie może obejmować obrazu ani tabeli. + Ta edycja nie jest tu możliwa. + + %d komórka z formułą pokazuje wynik, który Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formułę, a arkusz kalkulacyjny przeliczy ją ponownie. + %d komórki z formułami pokazują wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + %d komórek z formułami pokazuje wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + %d komórki z formułami pokazuje wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a63be4891f48..6b257cc32f71 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -75,4 +75,52 @@ Alterações não salvas Deseja salvá-las agora? Descartar + + Marcar PDF + Selecione o texto e depois uma ferramenta para marcá-lo + Desfazer + Refazer + Negrito + Itálico + Sublinhado + Tachado + Cor do texto + Realçar + Tamanho do texto + Cor de %1$s + %1$s pt + Realçar + Sublinhar + Tachar + Sublinhado ondulado + Desenhar + Preto + Cinza + Vermelho + Laranja + Amarelo + Verde + Azul + Roxo + Rosa + Sem realce + Pro + Parte do Pro + Formatar texto e adicionar ou juntar parágrafos faz parte do OpenDocument Reader Pro. + Marcar um PDF faz parte do OpenDocument Reader Pro. + Editar planilhas faz parte do OpenDocument Reader Pro. + Editar arquivos de texto simples faz parte do OpenDocument Reader Pro. + Agora não + Uma quebra de linha dentro de um parágrafo não pode ser salva. Pressione Enter para um novo parágrafo. + Essa célula contém uma fórmula e fica como está. + Digitar fórmulas ainda não é suportado. + Essa célula contém mais do que texto simples e fica como está. + Essa célula contém um desenho e fica como está. + Este documento não pode ser editado. + Uma edição não pode abranger uma imagem ou uma tabela. + Essa edição não é possível aqui. + + %d célula com fórmula mostra um resultado que sua edição desatualizou. O arquivo salvo mantém a fórmula, e um aplicativo de planilhas a calcula de novo. + %d células com fórmula mostram resultados que sua edição desatualizou. O arquivo salvo mantém as fórmulas, e um aplicativo de planilhas as calcula de novo. + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9021cf67e263..56502b9d10b1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -75,4 +75,54 @@ Несохранённые изменения Сохранить их сейчас? Не сохранять + + Разметить PDF + Выделите текст, затем выберите инструмент, чтобы его отметить + Отменить + Повторить + Полужирный + Курсив + Подчёркнутый + Зачёркнутый + Цвет текста + Выделение цветом + Размер текста + Цвет: %1$s + %1$s пт + Выделение + Подчёркивание + Зачёркивание + Волнистое подчёркивание + Рисование + Чёрный + Серый + Красный + Оранжевый + Жёлтый + Зелёный + Синий + Фиолетовый + Розовый + Без выделения + Pro + Входит в Pro + Форматирование текста, а также добавление и объединение абзацев входят в OpenDocument Reader Pro. + Разметка PDF входит в OpenDocument Reader Pro. + Редактирование электронных таблиц входит в OpenDocument Reader Pro. + Редактирование простых текстовых файлов входит в OpenDocument Reader Pro. + Не сейчас + Разрыв строки внутри абзаца нельзя сохранить. Нажмите Enter, чтобы начать новый абзац. + В этой ячейке формула, и она останется без изменений. + Ввод формул пока не поддерживается. + В этой ячейке больше чем простой текст, и она останется без изменений. + В этой ячейке рисунок, и она останется без изменений. + Этот документ нельзя редактировать. + Правка не может охватывать изображение или таблицу. + Такая правка здесь невозможна. + + %d ячейка с формулой показывает результат, который ваша правка сделала устаревшим. Сохранённый файл сохраняет формулу, и табличный редактор пересчитает её. + %d ячейки с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + %d ячеек с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + %d ячейки с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index cb9da75f6d1d..723776d194c3 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -75,4 +75,54 @@ Neshranjene spremembe Ali jih želite shraniti zdaj? Zavrzi + + Označi PDF + Izberite besedilo in nato orodje, da ga označite + Razveljavi + Uveljavi + Krepko + Ležeče + Podčrtano + Prečrtano + Barva besedila + Označevanje + Velikost besedila + Barva: %1$s + %1$s pt + Označevanje + Podčrtovanje + Prečrtovanje + Valovito podčrtovanje + Risanje + Črna + Siva + Rdeča + Oranžna + Rumena + Zelena + Modra + Vijolična + Rožnata + Brez označevanja + Pro + Del različice Pro + Oblikovanje besedila ter dodajanje ali združevanje odstavkov je del aplikacije OpenDocument Reader Pro. + Označevanje PDF-jev je del aplikacije OpenDocument Reader Pro. + Urejanje preglednic je del aplikacije OpenDocument Reader Pro. + Urejanje navadnih besedilnih datotek je del aplikacije OpenDocument Reader Pro. + Ne zdaj + Preloma vrstice znotraj odstavka ni mogoče shraniti. Za nov odstavek pritisnite Enter. + Ta celica vsebuje formulo in ostane nespremenjena. + Vnašanje formul še ni podprto. + Ta celica vsebuje več kot navadno besedilo in ostane nespremenjena. + Ta celica vsebuje risbo in ostane nespremenjena. + Tega dokumenta ni mogoče urejati. + Urejanje ne more segati prek slike ali tabele. + To urejanje tukaj ni mogoče. + + %d celica s formulo prikazuje rezultat, ki ga je vaše urejanje naredilo zastarelega. Shranjena datoteka ohrani formulo, program za preglednice pa jo znova izračuna. + %d celici s formulo prikazujeta rezultata, ki ju je vaše urejanje naredilo zastarela. Shranjena datoteka ohrani formuli, program za preglednice pa ju znova izračuna. + %d celice s formulo prikazujejo rezultate, ki jih je vaše urejanje naredilo zastarele. Shranjena datoteka ohrani formule, program za preglednice pa jih znova izračuna. + %d celic s formulo prikazuje rezultate, ki jih je vaše urejanje naredilo zastarele. Shranjena datoteka ohrani formule, program za preglednice pa jih znova izračuna. + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 073264695cf6..872ed4b8407a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -75,4 +75,52 @@ Osparade ändringar Vill du spara dem nu? Spara inte + + Markera PDF + Välj text och sedan ett verktyg för att markera den + Ångra + Gör om + Fet + Kursiv + Understruken + Genomstruken + Textfärg + Överstrykning + Textstorlek + Färg för %1$s + %1$s pt + Överstrykning + Understrykning + Genomstrykning + Vågig understrykning + Rita + Svart + Grå + Röd + Orange + Gul + Grön + Blå + Lila + Rosa + Ingen överstrykning + Pro + En del av Pro + Att formatera text och lägga till eller slå ihop stycken ingår i OpenDocument Reader Pro. + Att markera en PDF ingår i OpenDocument Reader Pro. + Att redigera kalkylblad ingår i OpenDocument Reader Pro. + Att redigera vanliga textfiler ingår i OpenDocument Reader Pro. + Inte nu + En radbrytning inuti ett stycke kan inte sparas. Tryck på Enter för ett nytt stycke. + Cellen innehåller en formel och förblir som den är. + Att skriva formler stöds inte ännu. + Cellen innehåller mer än vanlig text och förblir som den är. + Cellen innehåller en ritning och förblir som den är. + Det här dokumentet kan inte redigeras. + En redigering kan inte sträcka sig över en bild eller en tabell. + Den redigeringen är inte möjlig här. + + %d formelcell visar ett resultat som din redigering har gjort inaktuellt. Den sparade filen behåller formeln, och ett kalkylprogram räknar ut den igen. + %d formelceller visar resultat som din redigering har gjort inaktuella. Den sparade filen behåller formlerna, och ett kalkylprogram räknar ut dem igen. + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 8b583817e27b..2699f92868b6 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -75,4 +75,52 @@ Kaydedilmemiş değişiklikler Bunları şimdi kaydetmek ister misiniz? Kaydetme + + PDF\'yi işaretle + İşaretlemek için metni, ardından bir araç seçin + Geri al + Yinele + Kalın + İtalik + Altı çizili + Üstü çizili + Metin rengi + Vurgula + Metin boyutu + %1$s rengi + %1$s pt + Vurgula + Altını çiz + Üstünü çiz + Dalgalı alt çizgi + Çiz + Siyah + Gri + Kırmızı + Turuncu + Sarı + Yeşil + Mavi + Mor + Pembe + Vurgu yok + Pro + Pro\'nun bir parçası + Metni biçimlendirmek ve paragraf eklemek ya da birleştirmek OpenDocument Reader Pro\'nun bir parçasıdır. + PDF işaretlemek OpenDocument Reader Pro\'nun bir parçasıdır. + Elektronik tabloları düzenlemek OpenDocument Reader Pro\'nun bir parçasıdır. + Düz metin dosyalarını düzenlemek OpenDocument Reader Pro\'nun bir parçasıdır. + Şimdi değil + Paragraf içindeki bir satır sonu kaydedilemez. Yeni paragraf için Enter tuşuna basın. + Bu hücre bir formül içeriyor ve olduğu gibi kalıyor. + Formül yazmak henüz desteklenmiyor. + Bu hücre düz metinden fazlasını içeriyor ve olduğu gibi kalıyor. + Bu hücre bir çizim içeriyor ve olduğu gibi kalıyor. + Bu belge düzenlenemez. + Bir düzenleme bir resmin veya tablonun üzerinden geçemez. + Bu düzenleme burada mümkün değil. + + %d formül hücresi, düzenlemenizin eskittiği bir sonuç gösteriyor. Kaydedilen dosya formülü korur ve bir hesap tablosu uygulaması onu yeniden hesaplar. + %d formül hücresi, düzenlemenizin eskittiği sonuçlar gösteriyor. Kaydedilen dosya formülleri korur ve bir hesap tablosu uygulaması onları yeniden hesaplar. + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f557a23331f9..ee278e9a20a9 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -75,4 +75,51 @@ 有未保存的更改 要现在保存吗? 不保存 + + 标注 PDF + 先选择文本,再选择工具来标注 + 撤销 + 重做 + 粗体 + 斜体 + 下划线 + 删除线 + 文字颜色 + 突出显示 + 文字大小 + %1$s颜色 + %1$s 磅 + 突出显示 + 下划线 + 删除线 + 波浪线 + 绘图 + 黑色 + 灰色 + 红色 + 橙色 + 黄色 + 绿色 + 蓝色 + 紫色 + 粉色 + 无突出显示 + Pro + Pro 功能 + 设置文本格式以及添加或合并段落是 OpenDocument Reader Pro 的功能。 + 标注 PDF 是 OpenDocument Reader Pro 的功能。 + 编辑电子表格是 OpenDocument Reader Pro 的功能。 + 编辑纯文本文件是 OpenDocument Reader Pro 的功能。 + 以后再说 + 段落内的换行无法保存。按 Enter 键可新建段落。 + 该单元格包含公式,将保持不变。 + 尚不支持输入公式。 + 该单元格包含的不只是纯文本,将保持不变。 + 该单元格包含绘图,将保持不变。 + 此文档无法编辑。 + 编辑不能跨越图片或表格。 + 此处无法进行该编辑。 + + %d 个公式单元格显示的结果因您的编辑而过时。保存的文件会保留公式,电子表格应用会重新计算。 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 56c720167c46..e4a946848546 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -106,11 +106,11 @@ Italic Underline Strikethrough - Text colour + Text color Highlight - Font size - - %1$s colour + Text size + + %1$s color %1$s pt Highlight @@ -119,7 +119,7 @@ Squiggly underline Draw Black - Grey + Gray Red Orange Yellow @@ -128,20 +128,24 @@ Purple Pink No highlight - + Pro - Formatting text is part of Pro. - Starting or joining paragraphs is part of Pro. The free app edits the text inside one paragraph. - Marking up PDFs is part of Pro. + + Part of Pro + Formatting text, and adding or joining paragraphs, is part of OpenDocument Reader Pro. + Marking up a PDF is part of OpenDocument Reader Pro. + Editing spreadsheets is part of OpenDocument Reader Pro. + Editing plain text files is part of OpenDocument Reader Pro. + Not now A line break inside a paragraph cannot be saved. Press Enter for a new paragraph. - That cell holds a formula, which stays as it is. - Typing a formula is not supported yet. A number or some text is. - That cell holds more than plain text, so it stays as it is. - That cell holds a drawing, so it stays as it is. + That cell holds a formula and stays as it is. + Typing a formula is not supported yet. + That cell holds more than plain text and stays as it is. + That cell holds a drawing and stays as it is. This document cannot be edited. An edit cannot reach over a picture or a table. - That kind of edit is not supported. + That edit is not possible here. %d formula cell shows a result your edit made out of date. The saved file keeps the formula, and a spreadsheet app computes it again. %d formula cells show results your edit made out of date. The saved file keeps the formulas, and a spreadsheet app computes them again. diff --git a/app/src/noAds/java/app/opendocument/droid/nonfree/Linked.kt b/app/src/noAds/java/app/opendocument/droid/nonfree/Linked.kt index 4b1b643b92ec..c13e25a0537f 100644 --- a/app/src/noAds/java/app/opendocument/droid/nonfree/Linked.kt +++ b/app/src/noAds/java/app/opendocument/droid/nonfree/Linked.kt @@ -2,3 +2,6 @@ package app.opendocument.droid.nonfree /** Read through [Features]. */ internal const val LINKS_ADS = false + +/** Read through [Features]. */ +internal const val ADVANCED_EDITING = true From f665c145e4bb3e6b2640a245b5edc079f6e8a5f3 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 18 Sep 2026 21:25:27 +0200 Subject: [PATCH 3/7] Keep sheet cells and plain text free, as OpenDocument.ios does Lite now gates only what goes past typing inside a paragraph: new and joined paragraphs, formatting, and marks on a pdf. A sheet cell and a plain text file are edited in every build, so a sheet is rendered with its editor in lite too. The Pro offer is named by feature, formatting or pdf, and is reported as pro_gate_shown and pro_gate_tapped, the events iOS reports for the same gate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01F6AQY2k86AaPq12nxBfN7A --- CLAUDE.md | 14 ++++---- README.md | 6 ++-- .../droid/test/MainActivityTests.kt | 27 ++++++---------- .../droid/background/CoreLoader.kt | 3 +- .../opendocument/droid/nonfree/Features.kt | 7 ++-- .../droid/ui/activity/DocumentFragment.kt | 4 +-- .../droid/ui/activity/MainActivity.kt | 32 +++++++++++-------- app/src/main/res/values-ca/strings.xml | 2 -- app/src/main/res/values-cs/strings.xml | 2 -- app/src/main/res/values-da/strings.xml | 2 -- app/src/main/res/values-de/strings.xml | 2 -- app/src/main/res/values-es/strings.xml | 2 -- app/src/main/res/values-et/strings.xml | 2 -- app/src/main/res/values-fr/strings.xml | 2 -- app/src/main/res/values-ga/strings.xml | 2 -- app/src/main/res/values-hi/strings.xml | 2 -- app/src/main/res/values-it/strings.xml | 2 -- app/src/main/res/values-ja/strings.xml | 2 -- app/src/main/res/values-ko/strings.xml | 2 -- app/src/main/res/values-pl/strings.xml | 2 -- app/src/main/res/values-pt/strings.xml | 2 -- app/src/main/res/values-ru/strings.xml | 2 -- app/src/main/res/values-sl/strings.xml | 2 -- app/src/main/res/values-sv/strings.xml | 2 -- app/src/main/res/values-tr/strings.xml | 2 -- app/src/main/res/values-zh/strings.xml | 2 -- app/src/main/res/values/strings.xml | 2 -- 27 files changed, 45 insertions(+), 88 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 275bcf16712e..b9efa6b290e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,11 +118,12 @@ to the other, which `assembleDebug` catches - it builds all three. Code that has to *ask* reads `Features`, never the flavor name. `Features.withAds` comes from `LINKS_ADS`, which sits in `src/ads` and `src/noAds` next to the classes it stands for, so the flag cannot end up in a build whose code says otherwise. `Features.advancedEditing`, -from `ADVANCED_EDITING` in the same two files, is what pro is sold on: lite edits the text of -a document inside one paragraph, and pro and foss take every edit the core does - paragraphs, -formatting, sheet cells, plain text, pdf marks. `Features.offersEditing` is the one list of -it; the Edit button still stands on the core's answer, so in lite it offers pro instead. -`OpenDocument.ios` has the same flag beside its own `LINKS_ADS`. +from `ADVANCED_EDITING` in the same two files, is what pro is sold on: new and joined +paragraphs, formatting, and marks on a pdf. Every other edit the core takes - inside one +paragraph, a sheet cell, a plain text file - is in every build. `Features.offersEditing` is the +one list of it; the Edit button still stands on the core's answer, so over a pdf in lite it +offers pro instead. `OpenDocument.ios` draws the same line with the same flag, beside its own +`LINKS_ADS`. Do not add a `BuildConfig.FLAVOR` comparison back - it was what made `BillingManager` miss foss - and do not name a flag after a behaviour it only implies. The resource bool @@ -323,8 +324,7 @@ no second render, so the reader stays where they were. The page owns the operati and the refusals; `editing-bridge.js` (injected by `PageView` on every page load) forwards its callbacks. Lite narrows `HtmlConfig.editingScope` to `PARAGRAPH`, and the page refuses the rest with `outOfScope`, which `DocumentFragment` answers with the offer of pro, once an edit. -A kind of document lite does not edit is rendered without the scaffolding. A pdf needs none: -every pdf page carries `odr.annotation`. +A pdf needs no scaffolding: every pdf page carries `odr.annotation`. **Nothing is held open between the render and the save.** `CoreLoader.writeEdits` opens the cached copy again and applies the page's payload with the call its kind takes - diff --git a/README.md b/README.md index 603f1f080bad..01445d4430cc 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ die with the old package anyway. ## Editions Play carries two apps: OpenDocument Reader, free with ads, and OpenDocument Reader Pro, -paid. Both open everything. The free app edits the text of a document inside one paragraph; -Pro also starts and joins paragraphs, formats text, edits spreadsheets and plain text files, -and marks up PDFs. +paid. Both open everything, and both edit: the text of a document inside one paragraph, the +cells of a spreadsheet, and plain text files. Pro also starts and joins paragraphs, formats +text, and marks up PDFs. The F-Droid build and the apk on the release page are Pro without Play's review sheet: no ads, and every edit. They are built from this repository by anyone who wants to, so a gate in 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 7705143cc5f5..011bb8ba5278 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt @@ -169,34 +169,27 @@ class MainActivityTests { } } - /** A sheet takes cell edits where the edition offers them, and offers pro where it does not. */ + /** + * A sheet takes cell edits in every edition: only formatting, paragraphs and pdf marks are + * pro's. + */ @Test - fun aSheetIsEditedWhereTheEditionOffersIt() { + fun aSheetIsEditedInEveryEdition() { respondToOpenDocumentWith(requireTestFile("spreadsheet-test.ods")) openDocumentThroughPicker() waitForDocumentActions() - // the button stands on the core's answer, whichever the edition onView(withContentDescription(R.string.menu_edit)).perform(click()) val activity = mainActivityActivityTestRule.activity val pageView = requireNotNull(waitForDocumentFragment(activity, 10000)?.pageView) - if (Features.advancedEditing) { - Assert.assertTrue( - "the sheet should turn editable", - waitFor(EDIT_MODE_TIMEOUT_MS) { pageAnswers(pageView, "odr.editing.isEnabled()") }, - ) - } else { - awaitViewWithText(R.string.pro_offer_title) - onView(withText(R.string.pro_offer_title)).check(matches(isDisplayed())) - - Assert.assertFalse( - "lite should render a sheet without its editor", - pageAnswers(pageView, "odr.editing.isEditable()"), - ) - } + Assert.assertTrue( + "the sheet should turn editable", + waitFor(EDIT_MODE_TIMEOUT_MS) { pageAnswers(pageView, "odr.editing.isEnabled()") }, + ) + onView(withText(R.string.pro_offer_title)).check(doesNotExist()) } /** Lite edits a text document inside one paragraph, and the page itself holds it to that. */ 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 917a6e17ef85..d0c65f629c41 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -144,8 +144,7 @@ class CoreLoader(private val context: Context) { htmlConfig.textDocumentMargin = paging // the scaffolding only: the mode starts off, and odr.editing.enable() is what the edit - // button calls. not where this build does not offer the edit - the markup would buy - // nothing - and a pdf needs none of it: every pdf page carries odr.annotation + // button calls. a pdf needs none of it: every pdf page carries odr.annotation htmlConfig.editable = editing != EditingKind.ANNOTATION && Features.offersEditing(editing) // an edit that splits or merges a paragraph, and formatting, are pro's. the page refuses diff --git a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt index ce80b0d67516..ae1b45399de6 100644 --- a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt +++ b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt @@ -16,8 +16,9 @@ object Features { val withAds = LINKS_ADS /** - * Every edit the core takes: pro and foss. Lite edits the text of a document inside one - * paragraph, and the rest is what pro is sold on. + * The editing that goes past typing inside a paragraph - formatting, new and joined + * paragraphs - and marks on a pdf: pro and foss. Every other edit the core takes, a sheet cell + * and a plain text file among them, is in every build. */ val advancedEditing = ADVANCED_EDITING @@ -27,5 +28,5 @@ object Features { * editing there is. */ fun offersEditing(kind: EditingKind): Boolean = - kind == EditingKind.DOCUMENT || (kind.isEditable && advancedEditing) + kind.isEditable && (kind != EditingKind.ANNOTATION || advancedEditing) } 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 197510dee70a..07fe263ae0c2 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 @@ -521,7 +521,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } override fun onLocked() { - (requireActivity() as MainActivity).offerPro(R.string.pro_offer_formatting) + (requireActivity() as MainActivity).offerPro(MainActivity.ProFeature.FORMATTING) } } @@ -554,7 +554,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { if (!proOfferedThisEdit) { proOfferedThisEdit = true - (requireActivity() as MainActivity).offerPro(R.string.pro_offer_formatting) + (requireActivity() as MainActivity).offerPro(MainActivity.ProFeature.FORMATTING) } return 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 cf1b025163fb..74dea81d82ef 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 @@ -13,6 +13,7 @@ import android.view.View import android.widget.LinearLayout import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode as SupportActionMode @@ -24,7 +25,6 @@ import androidx.lifecycle.ViewModelProvider import app.opendocument.droid.R import app.opendocument.droid.background.CatchAllSetting import app.opendocument.droid.background.DocumentLoader -import app.opendocument.droid.background.EditingKind import app.opendocument.droid.background.NightModeSetting import app.opendocument.droid.background.PaginationSetting import app.opendocument.droid.background.PersistedUriPermissions @@ -621,17 +621,11 @@ class MainActivity : AppCompatActivity() { DocumentActions.ACTION_EDIT -> { analyticsManager.report("menu_edit") - // the button stands on the core's answer, so in lite it is there over a sheet, a - // plain text file and a pdf too, and says what pro would do with them + // the button stands on the core's answer, so in lite it is there over a pdf too, + // and says what pro would do with it val kind = documentFragment?.editingKind ?: return if (!Features.offersEditing(kind)) { - offerPro( - when (kind) { - EditingKind.ANNOTATION -> R.string.pro_offer_markup - EditingKind.SHEET -> R.string.pro_offer_sheets - else -> R.string.pro_offer_text - } - ) + offerPro(MainActivity.ProFeature.PDF) return } @@ -753,14 +747,15 @@ class MainActivity : AppCompatActivity() { * Says that what was just tried is pro's, and leads to the pro listing. Lite is the only build * that asks: pro and foss have every edit. */ - fun offerPro(messageRes: Int) { - analyticsManager.report("present_pro_offer") + fun offerPro(feature: ProFeature) { + // the names OpenDocument.ios reports the same gate under + analyticsManager.report("pro_gate_shown", "feature", feature.name.lowercase()) AlertDialog.Builder(this) .setTitle(R.string.pro_offer_title) - .setMessage(messageRes) + .setMessage(feature.message) .setPositiveButton(R.string.house_ad_cta_get_pro) { _, _ -> - analyticsManager.report("present_pro_offer_clicked") + analyticsManager.report("pro_gate_tapped", "feature", feature.name.lowercase()) buyAdRemoval() } @@ -768,6 +763,15 @@ class MainActivity : AppCompatActivity() { .show() } + /** What pro adds, as the reader runs into it. */ + enum class ProFeature(@param:StringRes val message: Int) { + /** Formatting text, and starting or joining a paragraph. */ + FORMATTING(R.string.pro_offer_formatting), + + /** Marking up a pdf. */ + PDF(R.string.pro_offer_markup), + } + /** What [buyAdRemoval] is for a build with no ad removal to sell. */ fun openSponsorPage() { analyticsManager.report(AnalyticsConstants.EVENT_ADD_TO_CART) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 0369b75a177f..6f9d3a98e55c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -108,8 +108,6 @@ Part de Pro Donar format al text, i afegir o unir paràgrafs, forma part d\'OpenDocument Reader Pro. Marcar un PDF forma part d\'OpenDocument Reader Pro. - Editar fulls de càlcul forma part d\'OpenDocument Reader Pro. - Editar fitxers de text pla forma part d\'OpenDocument Reader Pro. Ara no Un salt de línia dins d\'un paràgraf no es pot desar. Premeu Retorn per a un paràgraf nou. Aquesta cel·la conté una fórmula i es queda tal com està. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f2f457bf7678..9ed0b1e12133 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -108,8 +108,6 @@ Součást verze Pro Formátování textu a přidávání nebo spojování odstavců je součástí OpenDocument Reader Pro. Označování PDF je součástí OpenDocument Reader Pro. - Úpravy tabulek jsou součástí OpenDocument Reader Pro. - Úpravy prostých textových souborů jsou součástí OpenDocument Reader Pro. Teď ne Zalomení řádku uvnitř odstavce nelze uložit. Pro nový odstavec stiskněte Enter. Tato buňka obsahuje vzorec a zůstane beze změny. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index a34f76a60504..3fd2414c94df 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -108,8 +108,6 @@ En del af Pro Formatering af tekst og tilføjelse eller sammenlægning af afsnit er en del af OpenDocument Reader Pro. Markering af PDF-filer er en del af OpenDocument Reader Pro. - Redigering af regneark er en del af OpenDocument Reader Pro. - Redigering af almindelige tekstfiler er en del af OpenDocument Reader Pro. Ikke nu Et linjeskift inde i et afsnit kan ikke gemmes. Tryk på Enter for et nyt afsnit. Cellen indeholder en formel og forbliver, som den er. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d2f1b44c5ff5..f80ba0487005 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -108,8 +108,6 @@ Teil von Pro Text formatieren sowie Absätze einfügen oder zusammenführen ist Teil von OpenDocument Reader Pro. PDFs markieren ist Teil von OpenDocument Reader Pro. - Tabellendokumente bearbeiten ist Teil von OpenDocument Reader Pro. - Reine Textdateien bearbeiten ist Teil von OpenDocument Reader Pro. Nicht jetzt Ein Zeilenumbruch innerhalb eines Absatzes kann nicht gespeichert werden. Drücken Sie die Eingabetaste für einen neuen Absatz. Diese Zelle enthält eine Formel und bleibt, wie sie ist. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0cd40bad6a10..8e185c4002cf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -108,8 +108,6 @@ Parte de Pro Dar formato al texto, así como añadir o unir párrafos, forma parte de OpenDocument Reader Pro. Marcar un PDF forma parte de OpenDocument Reader Pro. - Editar hojas de cálculo forma parte de OpenDocument Reader Pro. - Editar archivos de texto sin formato forma parte de OpenDocument Reader Pro. Ahora no Un salto de línea dentro de un párrafo no se puede guardar. Pulse Intro para crear un párrafo nuevo. Esa celda contiene una fórmula y se queda como está. diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 6f2a8e447c75..e393335d08ff 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -108,8 +108,6 @@ Osa Pro-versioonist Teksti vormindamine ning lõikude lisamine või ühendamine on osa rakendusest OpenDocument Reader Pro. PDF-i märgistamine on osa rakendusest OpenDocument Reader Pro. - Arvutustabelite muutmine on osa rakendusest OpenDocument Reader Pro. - Lihttekstifailide muutmine on osa rakendusest OpenDocument Reader Pro. Mitte praegu Reavahetust lõigu sees ei saa salvestada. Uue lõigu jaoks vajuta Enter. Selles lahtris on valem ja see jääb nii, nagu on. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 54a9eaec52ec..831713b90d5c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -108,8 +108,6 @@ Inclus dans Pro La mise en forme du texte, ainsi que l\'ajout ou la fusion de paragraphes, fait partie d\'OpenDocument Reader Pro. L\'annotation des PDF fait partie d\'OpenDocument Reader Pro. - La modification des feuilles de calcul fait partie d\'OpenDocument Reader Pro. - La modification des fichiers texte brut fait partie d\'OpenDocument Reader Pro. Pas maintenant Un saut de ligne à l\'intérieur d\'un paragraphe ne peut pas être enregistré. Appuyez sur Entrée pour créer un nouveau paragraphe. Cette cellule contient une formule et reste telle quelle. diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 734023d18776..4c27845679ca 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -108,8 +108,6 @@ Cuid de Pro Is cuid de OpenDocument Reader Pro é téacs a fhormáidiú, agus míreanna a chur leis nó a chumasc. Is cuid de OpenDocument Reader Pro é PDF a mharcáil. - Is cuid de OpenDocument Reader Pro é scarbhileoga a chur in eagar. - Is cuid de OpenDocument Reader Pro é comhaid ghnáth-théacs a chur in eagar. Ní anois Ní féidir briseadh líne laistigh de mhír a shábháil. Brúigh Enter le haghaidh míre nua. Tá foirmle sa chill sin agus fanfaidh sí mar atá. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 6b518e11ec63..247fce4fc3ae 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -108,8 +108,6 @@ Pro का हिस्सा टेक्स्ट को फ़ॉर्मैट करना और अनुच्छेद जोड़ना या मिलाना OpenDocument Reader Pro का हिस्सा है। PDF चिह्नित करना OpenDocument Reader Pro का हिस्सा है। - स्प्रेडशीट संपादित करना OpenDocument Reader Pro का हिस्सा है। - सादी टेक्स्ट फ़ाइलें संपादित करना OpenDocument Reader Pro का हिस्सा है। अभी नहीं अनुच्छेद के भीतर लाइन ब्रेक सहेजा नहीं जा सकता। नए अनुच्छेद के लिए Enter दबाएँ। इस सेल में एक सूत्र है, इसलिए यह जैसा है वैसा ही रहेगा। diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ba2e5fb56cb4..cf6a79a2ec43 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -108,8 +108,6 @@ Parte di Pro Formattare il testo e aggiungere o unire paragrafi fa parte di OpenDocument Reader Pro. Annotare un PDF fa parte di OpenDocument Reader Pro. - Modificare i fogli di calcolo fa parte di OpenDocument Reader Pro. - Modificare i file di testo semplice fa parte di OpenDocument Reader Pro. Non ora Un\'interruzione di riga all\'interno di un paragrafo non può essere salvata. Premi Invio per un nuovo paragrafo. Questa cella contiene una formula e resta così com\'è. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d1ae2c25b702..0baa14c74ae5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -108,8 +108,6 @@ Pro の機能 テキストの書式設定と段落の追加・結合は OpenDocument Reader Pro の機能です。 PDF への書き込みは OpenDocument Reader Pro の機能です。 - スプレッドシートの編集は OpenDocument Reader Pro の機能です。 - プレーンテキストファイルの編集は OpenDocument Reader Pro の機能です。 今はしない 段落内の改行は保存できません。新しい段落にするには Enter を押してください。 このセルには数式があるため、そのままになります。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c24ef278c3e3..4a6da5d0b6b7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -108,8 +108,6 @@ Pro 기능 텍스트 서식 지정과 단락 추가 또는 병합은 OpenDocument Reader Pro 기능입니다. PDF 표시는 OpenDocument Reader Pro 기능입니다. - 스프레드시트 편집은 OpenDocument Reader Pro 기능입니다. - 일반 텍스트 파일 편집은 OpenDocument Reader Pro 기능입니다. 나중에 단락 안의 줄 바꿈은 저장할 수 없습니다. 새 단락을 만들려면 Enter를 누르세요. 이 셀에는 수식이 있어 그대로 유지됩니다. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 37304efe9956..78ea2abcbb1c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -108,8 +108,6 @@ Część wersji Pro Formatowanie tekstu oraz dodawanie i łączenie akapitów to część OpenDocument Reader Pro. Oznaczanie plików PDF to część OpenDocument Reader Pro. - Edycja arkuszy kalkulacyjnych to część OpenDocument Reader Pro. - Edycja zwykłych plików tekstowych to część OpenDocument Reader Pro. Nie teraz Podziału wiersza wewnątrz akapitu nie można zapisać. Naciśnij Enter, aby utworzyć nowy akapit. Ta komórka zawiera formułę i pozostaje bez zmian. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6b257cc32f71..8fe72dd6fff2 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -108,8 +108,6 @@ Parte do Pro Formatar texto e adicionar ou juntar parágrafos faz parte do OpenDocument Reader Pro. Marcar um PDF faz parte do OpenDocument Reader Pro. - Editar planilhas faz parte do OpenDocument Reader Pro. - Editar arquivos de texto simples faz parte do OpenDocument Reader Pro. Agora não Uma quebra de linha dentro de um parágrafo não pode ser salva. Pressione Enter para um novo parágrafo. Essa célula contém uma fórmula e fica como está. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 56502b9d10b1..a5567d273716 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -108,8 +108,6 @@ Входит в Pro Форматирование текста, а также добавление и объединение абзацев входят в OpenDocument Reader Pro. Разметка PDF входит в OpenDocument Reader Pro. - Редактирование электронных таблиц входит в OpenDocument Reader Pro. - Редактирование простых текстовых файлов входит в OpenDocument Reader Pro. Не сейчас Разрыв строки внутри абзаца нельзя сохранить. Нажмите Enter, чтобы начать новый абзац. В этой ячейке формула, и она останется без изменений. diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 723776d194c3..b147aaee1829 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -108,8 +108,6 @@ Del različice Pro Oblikovanje besedila ter dodajanje ali združevanje odstavkov je del aplikacije OpenDocument Reader Pro. Označevanje PDF-jev je del aplikacije OpenDocument Reader Pro. - Urejanje preglednic je del aplikacije OpenDocument Reader Pro. - Urejanje navadnih besedilnih datotek je del aplikacije OpenDocument Reader Pro. Ne zdaj Preloma vrstice znotraj odstavka ni mogoče shraniti. Za nov odstavek pritisnite Enter. Ta celica vsebuje formulo in ostane nespremenjena. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 872ed4b8407a..bd75192d6ebc 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -108,8 +108,6 @@ En del av Pro Att formatera text och lägga till eller slå ihop stycken ingår i OpenDocument Reader Pro. Att markera en PDF ingår i OpenDocument Reader Pro. - Att redigera kalkylblad ingår i OpenDocument Reader Pro. - Att redigera vanliga textfiler ingår i OpenDocument Reader Pro. Inte nu En radbrytning inuti ett stycke kan inte sparas. Tryck på Enter för ett nytt stycke. Cellen innehåller en formel och förblir som den är. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2699f92868b6..43a096d11c2e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -108,8 +108,6 @@ Pro\'nun bir parçası Metni biçimlendirmek ve paragraf eklemek ya da birleştirmek OpenDocument Reader Pro\'nun bir parçasıdır. PDF işaretlemek OpenDocument Reader Pro\'nun bir parçasıdır. - Elektronik tabloları düzenlemek OpenDocument Reader Pro\'nun bir parçasıdır. - Düz metin dosyalarını düzenlemek OpenDocument Reader Pro\'nun bir parçasıdır. Şimdi değil Paragraf içindeki bir satır sonu kaydedilemez. Yeni paragraf için Enter tuşuna basın. Bu hücre bir formül içeriyor ve olduğu gibi kalıyor. diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ee278e9a20a9..f06b2f99a123 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -108,8 +108,6 @@ Pro 功能 设置文本格式以及添加或合并段落是 OpenDocument Reader Pro 的功能。 标注 PDF 是 OpenDocument Reader Pro 的功能。 - 编辑电子表格是 OpenDocument Reader Pro 的功能。 - 编辑纯文本文件是 OpenDocument Reader Pro 的功能。 以后再说 段落内的换行无法保存。按 Enter 键可新建段落。 该单元格包含公式,将保持不变。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4a946848546..973675db9a38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -134,8 +134,6 @@ Part of Pro Formatting text, and adding or joining paragraphs, is part of OpenDocument Reader Pro. Marking up a PDF is part of OpenDocument Reader Pro. - Editing spreadsheets is part of OpenDocument Reader Pro. - Editing plain text files is part of OpenDocument Reader Pro. Not now A line break inside a paragraph cannot be saved. Press Enter for a new paragraph. From 77565b9edc15afb47d48ffcce66fc81b6aff24eb Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 18 Sep 2026 21:34:56 +0200 Subject: [PATCH 4/7] Match the tool row to the website and to OpenDocument.ios - Undo and redo leave the bar and end the tool row, for every kind of document: a sheet and a plain text file now get a row with only these two. The bar keeps save. - Text color is one control that opens the colors. Its bar and the highlight's bar follow the selection, as the website's do. - The colors are the set both apps offer. The first of each set is the website's default. - The notice about formula cells shows when their count grows, and not when an undo brings it down. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01F6AQY2k86AaPq12nxBfN7A --- .../droid/ui/EditActionModeCallback.kt | 53 ++------ .../droid/ui/activity/DocumentFragment.kt | 43 +++--- .../droid/ui/widget/EditingTools.kt | 127 ++++++++++++++---- app/src/main/res/menu/edit.xml | 17 +-- app/src/main/res/values-ca/strings.xml | 3 - app/src/main/res/values-cs/strings.xml | 3 - app/src/main/res/values-da/strings.xml | 3 - app/src/main/res/values-de/strings.xml | 3 - app/src/main/res/values-es/strings.xml | 3 - app/src/main/res/values-et/strings.xml | 3 - app/src/main/res/values-fr/strings.xml | 3 - app/src/main/res/values-ga/strings.xml | 3 - app/src/main/res/values-hi/strings.xml | 3 - app/src/main/res/values-it/strings.xml | 3 - app/src/main/res/values-ja/strings.xml | 3 - app/src/main/res/values-ko/strings.xml | 3 - app/src/main/res/values-pl/strings.xml | 3 - app/src/main/res/values-pt/strings.xml | 3 - app/src/main/res/values-ru/strings.xml | 3 - app/src/main/res/values-sl/strings.xml | 3 - app/src/main/res/values-sv/strings.xml | 3 - app/src/main/res/values-tr/strings.xml | 3 - app/src/main/res/values-zh/strings.xml | 3 - app/src/main/res/values/strings.xml | 3 - 24 files changed, 139 insertions(+), 161 deletions(-) diff --git a/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt b/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt index 8874c10608a0..67809d1e1f92 100644 --- a/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt +++ b/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt @@ -10,8 +10,8 @@ import app.opendocument.droid.ui.activity.DocumentFragment import app.opendocument.droid.ui.activity.MainActivity /** - * The edit mode: the bar on top with undo, redo and save, and under it the strip of tools the - * document has - see `EditingTools`. A pdf is marked up rather than edited, and has no redo. + * The edit mode: the bar on top with save, and under it the strip of tools the document has, undo + * and redo among them - see `EditingTools`. A pdf is marked up rather than edited. */ class EditActionModeCallback( private val activity: MainActivity, @@ -29,57 +29,27 @@ class EditActionModeCallback( mode.menuInflater.inflate(R.menu.edit, menu) - documentFragment.editStateListener = { mode.invalidate() } documentFragment.setEditing(true) return true } - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - menu.findItem(R.id.edit_redo).isVisible = - documentFragment.editingKind != EditingKind.ANNOTATION - - setEnabled(menu.findItem(R.id.edit_undo), documentFragment.canUndo) - setEnabled(menu.findItem(R.id.edit_redo), documentFragment.canRedo) - - return true - } - - /** A disabled action item keeps its icon as it was, so it is dimmed here. */ - private fun setEnabled(item: MenuItem, enabled: Boolean) { - item.isEnabled = enabled - item.icon = item.icon?.mutate()?.also { it.alpha = if (enabled) 255 else DISABLED_ALPHA } - } + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - when (item.itemId) { - R.id.edit_undo -> { - activity.analyticsManager.report("menu_edit_undo") - - documentFragment.undo() - } - - R.id.edit_redo -> { - activity.analyticsManager.report("menu_edit_redo") - - documentFragment.redo() - } + if (item.itemId != R.id.edit_save) { + return false + } - R.id.edit_save -> { - // OpenDocument.ios' name for this; menu_save is the button on the document itself - activity.analyticsManager.report("menu_edit_save") + // OpenDocument.ios' name for this; menu_save is the button on the document itself + activity.analyticsManager.report("menu_edit_save") - documentFragment.prepareSave({ activity.requestSave() }, false) - } - - else -> return false - } + documentFragment.prepareSave({ activity.requestSave() }, false) return true } override fun onDestroyActionMode(mode: ActionMode) { - documentFragment.editStateListener = null documentFragment.setEditing(false) // the page keeps its edits with the mode off, so they are asked about here rather than @@ -89,9 +59,4 @@ class EditActionModeCallback( activity.confirmLeavingEdits { documentFragment.discardEdits() } } } - - private companion object { - /** Material's opacity for a disabled icon, 38%. */ - const val DISABLED_ALPHA = 97 - } } 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 07fe263ae0c2..86feb98419e9 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 @@ -73,8 +73,8 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { /** Whether lite offered pro during this edit - see [showRefusal]. */ private var proOfferedThisEdit = false - /** Told when [canUndo] or [canRedo] changed, so the edit mode's bar can follow. */ - var editStateListener: (() -> Unit)? = null + /** How many formula cells the page last said were out of date - see `onCellsStale`. */ + private var staleCells = 0 /** Folding the actions back up is what back does first, while they are unfolded. */ private val actionsBackCallback = @@ -130,9 +130,6 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { /** Whether the page holds edits or marks no save has written - see [hasUnsavedEdits]. */ var editsDirty = false - var canUndo = false - var canRedo = false - // loads cannot be canceled once running, so results of abandoned loads // (e.g. user navigated back while the document was still loading) are // identified by their uri and dropped @@ -416,6 +413,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { if (editing) { proOfferedThisEdit = false + staleCells = 0 } pageView?.setEditing(document.editing, editing) @@ -444,14 +442,14 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { reload(requireLastRequest(), requireLastFile()) } - /** The strip under the bar, for the kinds of document that have tools to put in it. */ + /** The strip under the bar: what the kind of document takes, and undo and redo. */ private fun showEditingTools(document: LoadedDocument, editing: Boolean) { when { - !editing -> editingTools.hide() + !editing || !document.editing.isEditable -> editingTools.hide() document.editing == EditingKind.DOCUMENT -> editingTools.showFormatting(locked = !Features.advancedEditing) document.editing == EditingKind.ANNOTATION -> editingTools.showMarking() - else -> editingTools.hide() + else -> editingTools.showPlain() } } @@ -484,7 +482,10 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } override fun onCellsStale(count: Int) { - if (count == 0) { + // said as it grows: an undo that brings it down needs no word + val grew = count > staleCells + staleCells = count + if (!grew) { return } @@ -523,6 +524,18 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { override fun onLocked() { (requireActivity() as MainActivity).offerPro(MainActivity.ProFeature.FORMATTING) } + + override fun onUndo() { + analyticsManager.report("menu_edit_undo") + + undo() + } + + override fun onRedo() { + analyticsManager.report("menu_edit_redo") + + redo() + } } private fun setEditState(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) { @@ -531,18 +544,12 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } state.editsDirty = dirty - state.canUndo = canUndo - state.canRedo = canRedo - editStateListener?.invoke() + if (::editingTools.isInitialized) { + editingTools.setUndoState(canUndo, canRedo) + } } - val canUndo: Boolean - get() = ::state.isInitialized && state.canUndo - - val canRedo: Boolean - get() = ::state.isInitialized && state.canRedo - /** * What an edit the page did not take says. The page gives a reason and an english message for a * console; the wording a reader sees is ours. diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt index 9d1404d93e66..84b7e8ba5c34 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt @@ -21,7 +21,8 @@ import org.json.JSONObject /** * The strip of tools under the edit mode's bar: formatting for a text document or a presentation, - * the marking tools for a pdf. OpenDocument.website's viewer has the same strip under its bar. + * the marking tools for a pdf, and undo and redo at the end of every one of them. The website's + * viewer is the reference, and OpenDocument.ios has the same row. * * It only reports taps. What a tool does to the page is the page's, through `PageView`, and which * tool is on is what the page reports back - [setSelectionStyle] and [setArmedTool]. @@ -45,6 +46,10 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : /** A tool of pro's was tapped in a build without it. */ fun onLocked() + + fun onUndo() + + fun onRedo() } var listener: Listener? = null @@ -66,7 +71,13 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : private var textColorBar: View? = null private var highlightTool: View? = null + private var highlightBar: View? = null private var sizeTool: TextView? = null + private var undoTool: View? = null + private var redoTool: View? = null + + private var canUndo = false + private var canRedo = false init { LayoutInflater.from(context).inflate(R.layout.view_editing_tools, this, true) @@ -103,24 +114,21 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : R.string.tool_strikethrough, ) + // one control: the colors open under it, and the bar shows the selection's own val textColorTool = newTool(R.drawable.ic_text_color, R.string.tool_text_color) textColorBar = barOf(textColorTool).also { paintBar(it, textColor) } - textColorTool.setOnClickListener { - ifUnlocked { listener?.onFormat(JSONObject().put("color", hex(textColor))) } - } - row.addView(textColorTool) - addChevron(R.string.tool_text_color) { anchor -> - showPalette(anchor, TEXT_COLORS) { color -> - textColor = color - textColorBar?.let { paintBar(it, color) } - - listener?.onFormat(JSONObject().put("color", hex(color))) + textColorTool.setOnClickListener { anchor -> + ifUnlocked { + showPalette(anchor, TEXT_COLORS) { color -> + listener?.onFormat(JSONObject().put("color", hex(color))) + } } } + row.addView(textColorTool) // a split button: the tool turns the highlight on and off, the arrow picks its colour val highlight = newTool(R.drawable.ic_marker, R.string.tool_highlight) - paintBar(barOf(highlight), highlightColor) + highlightBar = barOf(highlight).also { paintBar(it, highlightColor) } highlight.setOnClickListener { ifUnlocked { // isNull is also true of a key the page left out, where the runs disagree @@ -142,7 +150,7 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : } highlightColor = color - paintBar(barOf(highlight), color) + highlightBar?.let { paintBar(it, color) } listener?.onFormat(JSONObject().put("highlight", hex(color))) } @@ -155,11 +163,22 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : sizeTool = size row.addView(size) + addUndoRedo(redo = true) + setSelectionStyle(selectionStyle) visibility = View.VISIBLE } + /** A sheet or a plain text file: nothing to format, so only the way back. */ + fun showPlain() { + reset(false) + + addUndoRedo(redo = true) + + visibility = View.VISIBLE + } + /** The five marking tools of a pdf, each with a colour of its own. */ fun showMarking() { reset(false) @@ -185,9 +204,43 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : } } + // a mark is taken back one at a time and never put back + addUndoRedo(redo = false) + visibility = View.VISIBLE } + /** What the page says can be taken back and put back. */ + fun setUndoState(canUndo: Boolean, canRedo: Boolean) { + this.canUndo = canUndo + this.canRedo = canRedo + + undoTool?.let { setUsable(it, canUndo) } + redoTool?.let { setUsable(it, canRedo) } + } + + /** Undo and redo are the page's in every edition, so they are never locked. */ + private fun addUndoRedo(redo: Boolean) { + val undo = newTool(R.drawable.ic_undo, R.string.action_undo) + undo.setOnClickListener { listener?.onUndo() } + undoTool = undo + row.addView(undo) + + if (redo) { + val tool = newTool(R.drawable.ic_redo, R.string.action_redo) + tool.setOnClickListener { listener?.onRedo() } + redoTool = tool + row.addView(tool) + } + + setUndoState(canUndo, canRedo) + } + + private fun setUsable(tool: View, usable: Boolean) { + tool.isEnabled = usable + tool.alpha = if (usable) 1f else DISABLED_ALPHA + } + /** Shows which of the toggles the selection has on, and the size it is set in. */ fun setSelectionStyle(style: JSONObject) { selectionStyle = style @@ -198,6 +251,24 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : highlightTool?.isSelected = !locked && !style.isNull("highlight") + // the bars follow the selection, as the website's do; where the runs disagree they keep + // what they showed + style + .optString("color") + .takeIf { !style.isNull("color") } + ?.let { parseColor(it) } + ?.let { color -> + textColorBar?.let { paintBar(it, color) } + } + style + .optString("highlight") + .takeIf { !style.isNull("highlight") } + ?.let { parseColor(it) } + ?.let { color -> + highlightColor = color + highlightBar?.let { paintBar(it, color) } + } + sizeTool?.text = style .optString("size", "") @@ -222,7 +293,10 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : markTools.clear() textColorBar = null highlightTool = null + highlightBar = null sizeTool = null + undoTool = null + redoTool = null selectionStyle = JSONObject() scrollTo(0, 0) @@ -379,27 +453,35 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : /** `#rrggbb`, the one spelling `odr.editing.format` takes. */ private fun hex(@ColorInt color: Int) = String.format("#%06x", color and 0xffffff) + private fun parseColor(hex: String): Int? = + try { + Color.parseColor(hex) + } catch (e: IllegalArgumentException) { + null + } + + /** Material's opacity for a disabled icon, 38%. */ + private const val DISABLED_ALPHA = 0.38f + /** Point sizes a document commonly uses. */ private val FONT_SIZES = listOf(8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48) + // the colors both apps offer, OpenDocument.ios' EditToolBar being the other copy. the + // first of each is the website's own default private val TEXT_COLORS = listOf( - NamedColor(0xff000000.toInt(), R.string.color_black), - NamedColor(0xff757575.toInt(), R.string.color_gray), + NamedColor(0xff191c1e.toInt(), R.string.color_black), NamedColor(0xffe53935.toInt(), R.string.color_red), - NamedColor(0xfffb8c00.toInt(), R.string.color_orange), - NamedColor(0xff43a047.toInt(), R.string.color_green), NamedColor(0xff1e88e5.toInt(), R.string.color_blue), - NamedColor(0xff8e24aa.toInt(), R.string.color_purple), + NamedColor(0xff43a047.toInt(), R.string.color_green), ) private val HIGHLIGHT_COLORS = listOf( NamedColor(0xfffff59d.toInt(), R.string.color_yellow), NamedColor(0xffc5e1a5.toInt(), R.string.color_green), - NamedColor(0xff90caf9.toInt(), R.string.color_blue), - NamedColor(0xfff48fb1.toInt(), R.string.color_pink), - NamedColor(0xffffcc80.toInt(), R.string.color_orange), + NamedColor(0xfff8bbd0.toInt(), R.string.color_pink), + NamedColor(0xffb3e5fc.toInt(), R.string.color_blue), NamedColor(Color.TRANSPARENT, R.string.color_none), ) @@ -407,9 +489,8 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : listOf( NamedColor(0xffffe633.toInt(), R.string.color_yellow), NamedColor(0xffe53935.toInt(), R.string.color_red), - NamedColor(0xff43a047.toInt(), R.string.color_green), NamedColor(0xff1e88e5.toInt(), R.string.color_blue), - NamedColor(0xff000000.toInt(), R.string.color_black), + NamedColor(0xff43a047.toInt(), R.string.color_green), ) /** diff --git a/app/src/main/res/menu/edit.xml b/app/src/main/res/menu/edit.xml index b0d16bf6d80f..e8624b58583f 100644 --- a/app/src/main/res/menu/edit.xml +++ b/app/src/main/res/menu/edit.xml @@ -1,23 +1,8 @@ - + - - - - Subratllat ondulat Dibuixa Negre - Gris Vermell - Taronja Groc Verd Blau - Lila Rosa Sense ressaltat Pro diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9ed0b1e12133..eb988f756c45 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -95,13 +95,10 @@ Vlnité podtržení Kreslit Černá - Šedá Červená - Oranžová Žlutá Zelená Modrá - Fialová Růžová Bez zvýraznění Pro diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 3fd2414c94df..0669132b8b72 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -95,13 +95,10 @@ Bølget understregning Tegn Sort - Grå Rød - Orange Gul Grøn Blå - Lilla Lyserød Ingen fremhævning Pro diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f80ba0487005..53d48500a29d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -95,13 +95,10 @@ Wellenlinie Zeichnen Schwarz - Grau Rot - Orange Gelb Grün Blau - Lila Rosa Keine Hervorhebung Pro diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8e185c4002cf..e050d71ff807 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -95,13 +95,10 @@ Subrayado ondulado Dibujar Negro - Gris Rojo - Naranja Amarillo Verde Azul - Morado Rosa Sin resaltado Pro diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index e393335d08ff..eabf600ce171 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -95,13 +95,10 @@ Lainjas allajoonimine Joonista Must - Hall Punane - Oranž Kollane Roheline Sinine - Lilla Roosa Esiletõstuta Pro diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 831713b90d5c..0967f5b64898 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -95,13 +95,10 @@ Soulignement ondulé Dessiner Noir - Gris Rouge - Orange Jaune Vert Bleu - Violet Rose Aucun surlignage Pro diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 4c27845679ca..4b7ddf9c9e8d 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -95,13 +95,10 @@ Líne chasta faoi Tarraing Dubh - Liath Dearg - Oráiste Buí Glas Gorm - Corcra Bándearg Gan aibhsiú Pro diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 247fce4fc3ae..81eb4697fa12 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -95,13 +95,10 @@ लहरदार रेखांकन ड्रॉ करें काला - धूसर लाल - नारंगी पीला हरा नीला - बैंगनी गुलाबी कोई हाइलाइट नहीं Pro diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cf6a79a2ec43..5616e5c050f8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -95,13 +95,10 @@ Sottolineatura ondulata Disegna Nero - Grigio Rosso - Arancione Giallo Verde Blu - Viola Rosa Nessuna evidenziazione Pro diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0baa14c74ae5..3eb66f983533 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -95,13 +95,10 @@ 波線 描画 - グレー - オレンジ - ピンク ハイライトなし Pro diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 4a6da5d0b6b7..7c530efa5642 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -95,13 +95,10 @@ 물결 밑줄 그리기 검정 - 회색 빨강 - 주황 노랑 초록 파랑 - 보라 분홍 강조 표시 없음 Pro diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 78ea2abcbb1c..68fc190eeb99 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -95,13 +95,10 @@ Podkreślenie falowane Rysowanie Czarny - Szary Czerwony - Pomarańczowy Żółty Zielony Niebieski - Fioletowy Różowy Bez wyróżnienia Pro diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8fe72dd6fff2..e746a5eeb998 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -95,13 +95,10 @@ Sublinhado ondulado Desenhar Preto - Cinza Vermelho - Laranja Amarelo Verde Azul - Roxo Rosa Sem realce Pro diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a5567d273716..d8c7e08510b9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -95,13 +95,10 @@ Волнистое подчёркивание Рисование Чёрный - Серый Красный - Оранжевый Жёлтый Зелёный Синий - Фиолетовый Розовый Без выделения Pro diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index b147aaee1829..b81c6a38b41b 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -95,13 +95,10 @@ Valovito podčrtovanje Risanje Črna - Siva Rdeča - Oranžna Rumena Zelena Modra - Vijolična Rožnata Brez označevanja Pro diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index bd75192d6ebc..c2b3dd4d2c14 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -95,13 +95,10 @@ Vågig understrykning Rita Svart - Grå Röd - Orange Gul Grön Blå - Lila Rosa Ingen överstrykning Pro diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 43a096d11c2e..52a1f590206f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -95,13 +95,10 @@ Dalgalı alt çizgi Çiz Siyah - Gri Kırmızı - Turuncu Sarı Yeşil Mavi - Mor Pembe Vurgu yok Pro diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f06b2f99a123..c7ab041c5319 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -95,13 +95,10 @@ 波浪线 绘图 黑色 - 灰色 红色 - 橙色 黄色 绿色 蓝色 - 紫色 粉色 无突出显示 Pro diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 973675db9a38..d4aea09e923b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,13 +119,10 @@ Squiggly underline Draw Black - Gray Red - Orange Yellow Green Blue - Purple Pink No highlight From 2092d2c61488a8363a2e9bc696376b6a1fb805dd Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 19 Sep 2026 09:57:15 +0200 Subject: [PATCH 5/7] Take core 7.1.0, and leave the tool buttons to the page Core 7.1.0 records what an Android keyboard types into a document (OpenDocument.core#905). Before, a save lost it. The release also adds odr.onAnnotationChange and odr.annotation.press and recolor (OpenDocument.core#906). - editing-bridge.js only points the page's callbacks at PageView, and sets markOnSelection. The count of marks now comes from onAnnotationChange, so the bridge no longer counts after each pointer event. - PageView calls odr.annotation.press, recolor, undo and setTool directly. - CoreLoader writes a plain text file with TextFile.edit and save, because writeEdited is deprecated. It also hands an empty envelope to Document.edit. Neither 7.0.0 nor 7.1.0 refuses one, so the guard and its comment were wrong. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3sFjPT7zd8fvmpqhznzEP --- CLAUDE.md | 2 +- app/src/main/assets/editing-bridge.js | 111 ++---------------- .../droid/background/CoreLoader.kt | 14 +-- .../droid/ui/widget/EditingTools.kt | 2 +- .../opendocument/droid/ui/widget/PageView.kt | 14 +-- gradle/libs.versions.toml | 2 +- 6 files changed, 25 insertions(+), 120 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b9efa6b290e9..717ec209fb62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -328,7 +328,7 @@ A pdf needs no scaffolding: every pdf page carries `odr.annotation`. **Nothing is held open between the render and the save.** `CoreLoader.writeEdits` opens the cached copy again and applies the page's payload with the call its kind takes - -`Document.edit` and `save`, `TextFile.writeEdited`, `PdfFile.annotate`. An edit that throws +`Document.edit` and `save`, `TextFile.edit` and `save`, `PdfFile.annotate`. An edit that throws halfway leaves the document it was applied to half changed, so a retry must not start from it. ### Storage access diff --git a/app/src/main/assets/editing-bridge.js b/app/src/main/assets/editing-bridge.js index f1c07f815276..8dde6a001446 100644 --- a/app/src/main/assets/editing-bridge.js +++ b/app/src/main/assets/editing-bridge.js @@ -1,24 +1,16 @@ -// Injected by PageView into every page the core serves, after the page's own scripts. It wires -// the page's editing callbacks to the app's bridge, and holds the one thing the app cannot see from -// outside: whether text is selected when a marking tool is pressed. -// -// The half of OpenDocument.website's frame-bridge.js that an app needs, over addJavascriptInterface -// rather than postMessage. +// Injected by PageView into every page the core serves, after the page's own scripts. It points +// the page's editing callbacks at the app's bridge; what they mean is the page's. (function () { "use strict"; var odr = window.odr; var bridge = window.paragraphListener; - // a page with no scripts of the core's, a page this was injected into already, or a page the - // bridge is not attached to - if (!odr || !bridge || odr.androidEditing) { + // a page with no scripts of the core's, or a page the bridge is not attached to + if (!odr || !bridge) { return; } - var annotation = odr.annotation || null; - - // the page's callbacks, forwarded: the page owns what they mean, the app what they say odr.onEditChange = function (event) { bridge.editChanged(!!event.dirty, !!event.canUndo, !!event.canRedo); }; @@ -31,98 +23,13 @@ odr.onCellsStale = function (detail) { bridge.cellsStale(detail && detail.cells ? detail.cells.length : 0); }; + odr.onAnnotationChange = function (event) { + bridge.marksChanged(event.count); + }; - // the annotator has no callback of its own, so the count of pending marks is reported after - // every gesture that can change it. A mark taken from a selection settles 50ms after the pointer - // lifts; this waits a little longer - var reportedMarks = -1; - - function reportMarks() { - var count = annotation.list().length; - if (count === reportedMarks) { - return; - } - reportedMarks = count; - bridge.marksChanged(count); - } - - function reportMarksSoon() { - window.setTimeout(reportMarks, 120); - } - - if (annotation) { + if (odr.annotation) { // an armed tool marks a selection as it is made, which is what a touch screen needs: with a // selection standing, the selection's own toolbar is over the page - annotation.setOptions({ markOnSelection: true }); - document.addEventListener("pointerup", reportMarksSoon); - document.addEventListener("pointercancel", reportMarksSoon); - document.addEventListener("selectionchange", reportMarksSoon); - } - - function hasSelection() { - var selection = window.getSelection(); - return !!selection && !selection.isCollapsed && selection.toString().length > 0; - } - - /// Marks the selection once with @p tool, and leaves no tool armed. - function markOnce(tool) { - annotation.setTool(tool); - annotation.mark(); - // disarmed before the selection is cleared, so the clear cannot mark it a second time - annotation.setTool(null); - var selection = window.getSelection(); - if (selection) { - selection.removeAllRanges(); - } - reportMarks(); + odr.annotation.setOptions({ markOnSelection: true }); } - - odr.androidEditing = { - /// A tool button was pressed. With text selected, the tool marks that selection once. Without - /// one, the press arms the tool, and a second press disarms it. @p rgb is 0..1 per component. - /// Answers the tool left armed, or null. - tool: function (tool, rgb, width) { - if (!annotation) { - return null; - } - annotation.setColor(rgb); - annotation.setWidth(width); - if (tool !== "ink" && hasSelection()) { - markOnce(tool); - } else if (annotation.getTool() === tool) { - annotation.setTool(null); - } else { - annotation.setTool(tool); - } - return annotation.getTool(); - }, - - /// A new colour for @p tool: marks a selection once, recolours the tool if it is armed. - recolor: function (tool, rgb, width) { - if (!annotation) { - return null; - } - if (tool !== "ink" && hasSelection()) { - annotation.setColor(rgb); - annotation.setWidth(width); - markOnce(tool); - } else if (annotation.getTool() === tool) { - annotation.setColor(rgb); - } - return annotation.getTool(); - }, - - disarm: function () { - if (annotation) { - annotation.setTool(null); - } - }, - - undoMark: function () { - if (annotation) { - annotation.undo(); - reportMarks(); - } - }, - }; })(); 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 d0c65f629c41..425af46bb300 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -24,7 +24,6 @@ import app.opendocument.droid.nonfree.CrashManager import app.opendocument.droid.nonfree.Features import java.io.File import java.io.IOException -import org.json.JSONObject /** * Loads documents through odrcore and publishes them on a local http server. @@ -324,16 +323,15 @@ class CoreLoader(private val context: Context) { when (editingOf(file)) { EditingKind.NONE -> throw IOException("cannot be written back: $inputPath") EditingKind.ANNOTATION -> outputFile.writeBytes(file.asPdfFile().annotate(payload)) - EditingKind.TEXT -> outputFile.writeBytes(file.asTextFile().writeEdited(payload)) + EditingKind.TEXT -> + file.asTextFile().let { textFile -> + textFile.edit(payload) + textFile.save(outputFile.path) + } EditingKind.DOCUMENT, EditingKind.SHEET -> file.asDocumentFile().document().use { document -> - // an envelope with no operations is refused, and a save with nothing to - // apply is the file as it was - if (JSONObject(payload).getJSONArray("ops").length() > 0) { - document.edit(payload) - } - + document.edit(payload) document.save(outputFile.path) } } diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt index 84b7e8ba5c34..f3979b120932 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt @@ -40,7 +40,7 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : /** * A marking tool was pressed, or picked a new [color] where [recolor] - see - * `editing-bridge.js` for what either does to a selection. + * `odr.annotation.press` and `recolor` for what either does to a selection. */ fun onMarkTool(tool: String, @ColorInt color: Int, recolor: Boolean) 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 84e29005f32f..fcfa76fe8de3 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 @@ -473,7 +473,7 @@ constructor(context: Context, attributeSet: AttributeSet?) : when { editingKind == EditingKind.ANNOTATION -> if (isEditing) "void 0" - else "window.odr && odr.androidEditing && odr.androidEditing.disarm()" + else "window.odr && odr.annotation && odr.annotation.setTool(null)" isEditing -> "window.odr && odr.editing && odr.editing.enable()" else -> "window.odr && odr.editing && odr.editing.disable()" }, @@ -484,7 +484,7 @@ constructor(context: Context, attributeSet: AttributeSet?) : fun undo() { evaluateJavascript( if (editingKind == EditingKind.ANNOTATION) - "window.odr && odr.androidEditing && odr.androidEditing.undoMark()" + "window.odr && odr.annotation && odr.annotation.undo()" else "window.odr && odr.editing && odr.editing.undo()", null, ) @@ -505,8 +505,8 @@ constructor(context: Context, attributeSet: AttributeSet?) : } /** - * A marking tool was pressed, or [recolor] given a new colour. [callback] gets the tool left - * armed, or null. + * A marking tool was pressed, or [recolor] given a new colour - `odr.annotation.press` and + * `recolor` decide what that does to a selection. [callback] gets the tool left armed, or null. */ fun pressMarkTool( tool: String, @@ -519,11 +519,11 @@ constructor(context: Context, attributeSet: AttributeSet?) : "[${android.graphics.Color.red(color) / 255f}," + "${android.graphics.Color.green(color) / 255f}," + "${android.graphics.Color.blue(color) / 255f}]" - val method = if (recolor) "recolor" else "tool" + val method = if (recolor) "recolor" else "press" evaluateJavascript( - "window.odr && odr.androidEditing ? " + - "odr.androidEditing.$method(${JSONObject.quote(tool)}, $rgb, $width) : null" + "window.odr && odr.annotation ? odr.annotation.$method(" + + "${JSONObject.quote(tool)}, {color: $rgb, width: $width}) : null" ) { callback(decodeString(it)) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ba0fd1c84436..49535e932e0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ googleJavaFormat = "1.35.0" ktfmt = "0.64" # odrcore's JNI bindings, java and native in one AAR, published from OpenDocument.core -odrCore = "7.0.0" +odrCore = "7.1.0" androidxAnnotation = "1.10.0" androidxAppcompat = "1.8.0" From a38a60ee0bf1f459002d0b4050bc0fa44711450f Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 19 Sep 2026 10:46:10 +0200 Subject: [PATCH 6/7] Review the editing PR: pass the kind to the save, shorten the comments - DocumentSaver passes LoadedDocument.editing to CoreLoader.writeEdits, so a save no longer opens the document a second time only to ask what kind it is. - The page callbacks that show a snackbar or a dialog return if the fragment is detached. - The comment in fragment_document.xml said that the tools are gone for a sheet and a plain text file. Both show undo and redo. - writeEdits no longer logs the payload, which holds the user's text. - The comments are shorter. The reasons are in the PR and in CLAUDE.md. - CHANGELOG.md has entries for the edit mode, pro and the core 7 rendering fixes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3sFjPT7zd8fvmpqhznzEP --- CHANGELOG.md | 7 +++ README.md | 3 +- .../app/opendocument/droid/nonfree/Linked.kt | 2 +- .../app/opendocument/droid/test/CoreTest.kt | 8 +-- .../droid/test/MainActivityTests.kt | 5 +- .../droid/background/CoreLoader.kt | 54 ++++++++----------- .../droid/background/DocumentRequest.kt | 6 +-- .../droid/background/DocumentSaver.kt | 8 +-- .../droid/background/EditingKind.kt | 9 ++-- .../opendocument/droid/nonfree/Features.kt | 12 +---- .../droid/ui/EditActionModeCallback.kt | 10 ++-- .../droid/ui/activity/DocumentFragment.kt | 35 +++++------- .../droid/ui/activity/MainActivity.kt | 14 ++--- .../droid/ui/widget/EditingTools.kt | 29 +++------- .../opendocument/droid/ui/widget/PageView.kt | 17 ++---- app/src/main/res/layout/fragment_document.xml | 5 +- 16 files changed, 79 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75be9c9fbb0f..620ef0069db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ takes 500 characters, so not everything here reaches the store. ## Unreleased +- Editing reaches PowerPoint files, spreadsheet cells and plain text files, has undo and + redo, and no longer loads the document again, so the page stays where it was. +- Pro also formats text, starts and joins paragraphs, and marks up PDFs: highlight, + underline, strike out, squiggly underline, and drawing. +- Leaving the edit mode with unsaved changes asks whether to save or discard them. +- Word files show a text's shading, and text that is both underlined and struck through + shows both lines. - A spreadsheet too big to show in full says so, and names how many of its rows and columns are on screen. It used to stop without a word. - How much of a sheet is shown follows the device's memory now, rather than one diff --git a/README.md b/README.md index 01445d4430cc..142d19d1d2f1 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,7 @@ cells of a spreadsheet, and plain text files. Pro also starts and joins paragrap text, and marks up PDFs. The F-Droid build and the apk on the release page are Pro without Play's review sheet: no -ads, and every edit. They are built from this repository by anyone who wants to, so a gate in -them would be one line to change. +ads, and every edit. ## Translations diff --git a/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt b/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt index 3f4a4136fdef..876a0c90ddf1 100644 --- a/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt +++ b/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt @@ -3,5 +3,5 @@ package app.opendocument.droid.nonfree /** Read through [Features]. */ internal const val LINKS_ADS = true -/** Read through [Features]. Lite edits inside a paragraph and sells the rest. */ +/** Read through [Features]. */ internal const val ADVANCED_EDITING = false 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 031ea4f27826..14ddc8e8cfb5 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt @@ -40,10 +40,7 @@ class CoreTest { assertEditRoundTrips("pptx-edit", pptxTestFile) } - /** - * Writes one run of [file] the way the page's editor does - an envelope naming the run by the - * id the render put on it - and reads the saved file back. - */ + /** Writes one run of [file] as the page's editor does, and reads the saved file back. */ private fun assertEditRoundTrips(prefix: String, file: File) { val html = URL(coreLoader.host(prefix, file.absolutePath, askEditing = true)[0].url).readText() @@ -59,6 +56,7 @@ class CoreTest { file.absolutePath, null, null, + coreLoader.editing, payload, File(cacheDir(), "$prefix-result").path, ) @@ -82,6 +80,7 @@ class CoreTest { pdfTestFile.absolutePath, null, null, + EditingKind.ANNOTATION, payload, File(cacheDir(), "pdf-annotate-result").path, ) @@ -113,6 +112,7 @@ class CoreTest { text.absolutePath, null, null, + EditingKind.TEXT, """{"version":2,"ops":[{"op":"setContent","text":"$EDITED"}]}""", File(cacheDir(), "text-edit-result").path, ) 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 011bb8ba5278..69f1959719fb 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt @@ -169,10 +169,7 @@ class MainActivityTests { } } - /** - * A sheet takes cell edits in every edition: only formatting, paragraphs and pdf marks are - * pro's. - */ + /** A sheet takes cell edits in every edition. */ @Test fun aSheetIsEditedInEveryEdition() { respondToOpenDocumentWith(requireTestFile("spreadsheet-test.ods")) 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 425af46bb300..52dc8ede0d28 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -28,8 +28,7 @@ import java.io.IOException /** * Loads documents through odrcore and publishes them on a local http server. * - * Owns the process wide core state: the one-time initialization and the single http server. No - * document is held open between a render and a save: [writeEdits] opens the cached copy again. + * Owns the process wide core state: the one-time initialization and the single http server. */ class CoreLoader(private val context: Context) { @@ -40,10 +39,7 @@ class CoreLoader(private val context: Context) { /** Counts the renders, so each one publishes under a prefix of its own - see [render]. */ private var renderCount = 0 - /** - * What the user can change in the document [host] last opened, where it was asked to find out. - * The core's own answer - see [editingOf]. - */ + /** What the user can change in the document [host] last opened with `askEditing`. */ var editing: EditingKind = EditingKind.NONE private set @@ -103,9 +99,8 @@ class CoreLoader(private val context: Context) { * Opens [inputPath], translates it to html and publishes it on the shared http server under * [prefix], replacing whatever was published before. * - * [askEditing] finds out what the user can change and sets [editing]; a page the core can write - * back is rendered with the editor in it, so the edit mode needs no second render. - * [declaredType] is what the document is called - see [openFile]. + * [askEditing] sets [editing], and renders an editable document with its editor. [declaredType] + * is what the document is called - see [openFile]. */ fun host( prefix: String, @@ -142,12 +137,10 @@ class CoreLoader(private val context: Context) { htmlConfig.relativeResourcePaths = false htmlConfig.textDocumentMargin = paging - // the scaffolding only: the mode starts off, and odr.editing.enable() is what the edit - // button calls. a pdf needs none of it: every pdf page carries odr.annotation + // the mode starts off; a pdf page carries odr.annotation without it htmlConfig.editable = editing != EditingKind.ANNOTATION && Features.offersEditing(editing) - // an edit that splits or merges a paragraph, and formatting, are pro's. the page refuses - // them in lite with outOfScope, which DocumentFragment answers with the offer + // lite: the page refuses the rest with outOfScope, and DocumentFragment offers pro htmlConfig.editingScope = if (Features.advancedEditing) HtmlEditingScope.DOCUMENT else HtmlEditingScope.PARAGRAPH @@ -273,11 +266,13 @@ class CoreLoader(private val context: Context) { return file.decrypt(password) } - /** - * The document with [payload] from the page applied, written to a file of ours. Null if that - * failed. - */ - fun writeEdits(request: DocumentRequest, file: IdentifiedFile, payload: String): File? { + /** The document with [payload] from the page applied, written to a file of ours, or null. */ + fun writeEdits( + request: DocumentRequest, + file: IdentifiedFile, + kind: EditingKind, + payload: String, + ): File? { try { val cachedFile = checkNotNull(FileCache.getCacheFile(context, file.cacheUri)) { @@ -288,6 +283,7 @@ class CoreLoader(private val context: Context) { cachedFile.path, request.password, declaredType(file), + kind, payload, File(FileCache.getCacheDirectory(cachedFile), "edited").path, ) @@ -299,28 +295,24 @@ class CoreLoader(private val context: Context) { } /** - * Opens [inputPath] again, applies [payload] and writes the result next to [outputPathPrefix], - * with the extension of the file's own type. - * - * Opened again rather than held open since the render: an edit that throws halfway leaves the - * document it was applied to half changed, and a second attempt must not start from that. + * Opens [inputPath] again, applies [payload] with the call [kind] takes and writes the result + * to [outputPathPrefix] plus the file type's extension. Never a document held open since the + * render: a failed edit can leave it half changed. */ fun writeEdits( inputPath: String, password: String?, declaredType: FileType?, + kind: EditingKind, payload: String, outputPathPrefix: String, ): File { openDecrypted(inputPath, password, declaredType).use { file -> - // the file type's extension, not [Odr.fileTypeToString], which is its name - and a - // name like "ooxml_encrypted" is not something a file can be called + // not Odr.fileTypeToString, which gives names like "ooxml_encrypted" val extension = Odr.fileExtensionByFileType(file.fileType()) val outputFile = File("$outputPathPrefix.$extension") - Log.d(TAG, "edit payload: $payload") - - when (editingOf(file)) { + when (kind) { EditingKind.NONE -> throw IOException("cannot be written back: $inputPath") EditingKind.ANNOTATION -> outputFile.writeBytes(file.asPdfFile().annotate(payload)) EditingKind.TEXT -> @@ -341,10 +333,8 @@ class CoreLoader(private val context: Context) { } /** - * What the user can change in [file]. The format's capabilities come first, answered without - * decoding, so a format that declares no editing is not opened just to be told no. The file - * itself is the precise answer: a pdf repaired on open takes no marks, a document decrypted - * from a password cannot be saved. + * What the user can change in [file]. The capabilities are asked first, because they need no + * decode; the file itself has the final answer. */ private fun editingOf(file: DecodedFile): EditingKind { val capabilities = file.capabilities() diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt b/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt index 137511939954..6bceef6bc9be 100644 --- a/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt +++ b/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt @@ -12,11 +12,7 @@ import android.os.Parcelable */ class DocumentRequest(val uri: Uri, val persistentUri: Boolean) : Parcelable { - /** - * Whether the edit mode is on. The render does not depend on it - a document the core can write - * back always carries its editor - so it only says which mode the page is put into once loaded, - * including after a save loads the written document again. - */ + /** Whether the edit mode is on. The render does not depend on it. */ var editable: Boolean = false var password: String? = null diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt b/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt index 1203db1ef2e8..73c09a623fce 100644 --- a/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt +++ b/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt @@ -36,9 +36,11 @@ class DocumentSaver( try { val fileToSave = if (payload != null) { - coreLoader.writeEdits(document.request, document.file, payload)?.also { - edited = it - } ?: throw RuntimeException("writing the edits failed") + coreLoader + .writeEdits(document.request, document.file, document.editing, payload) + ?.also { + edited = it + } ?: throw RuntimeException("writing the edits failed") } else { // "full save" from the main UI checkNotNull(FileCache.getCacheFile(context, document.file.cacheUri)) { diff --git a/app/src/main/java/app/opendocument/droid/background/EditingKind.kt b/app/src/main/java/app/opendocument/droid/background/EditingKind.kt index ea6a6df67452..9fc7ba5d7905 100644 --- a/app/src/main/java/app/opendocument/droid/background/EditingKind.kt +++ b/app/src/main/java/app/opendocument/droid/background/EditingKind.kt @@ -1,9 +1,6 @@ package app.opendocument.droid.background -/** - * What the user can change in a document, as the core answers it for that document. The page has - * one editor per kind, and each kind saves through its own core call - see `CoreLoader.writeEdits`. - */ +/** What the user can change in a document, as the core answers it. Each kind saves its own way. */ enum class EditingKind { /** Nothing: the core cannot write this document back. */ NONE, @@ -11,13 +8,13 @@ enum class EditingKind { /** A plain text file: its text, and nothing else. */ TEXT, - /** A text document or a presentation: its text, and in pro its formatting too. */ + /** A text document or a presentation. */ DOCUMENT, /** A spreadsheet: one cell at a time. */ SHEET, - /** A pdf, which takes marks drawn over it rather than edits. Pro only. */ + /** A pdf, which takes marks rather than edits. */ ANNOTATION; val isEditable: Boolean diff --git a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt index ae1b45399de6..b8a219b1be51 100644 --- a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt +++ b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt @@ -15,18 +15,10 @@ object Features { */ val withAds = LINKS_ADS - /** - * The editing that goes past typing inside a paragraph - formatting, new and joined - * paragraphs - and marks on a pdf: pro and foss. Every other edit the core takes, a sheet cell - * and a plain text file among them, is in every build. - */ + /** Formatting, new and joined paragraphs, and pdf marks: pro and foss. */ val advancedEditing = ADVANCED_EDITING - /** - * Whether this build lets the user into the edit mode for [kind]. The core answers whether the - * document can be edited at all; this is the edition's policy on top of it, and the one list of - * editing there is. - */ + /** Whether this build opens the edit mode for [kind], which the core decided. */ fun offersEditing(kind: EditingKind): Boolean = kind.isEditable && (kind != EditingKind.ANNOTATION || advancedEditing) } diff --git a/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt b/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt index 67809d1e1f92..100b866dcb48 100644 --- a/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt +++ b/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt @@ -9,10 +9,7 @@ import app.opendocument.droid.background.EditingKind import app.opendocument.droid.ui.activity.DocumentFragment import app.opendocument.droid.ui.activity.MainActivity -/** - * The edit mode: the bar on top with save, and under it the strip of tools the document has, undo - * and redo among them - see `EditingTools`. A pdf is marked up rather than edited. - */ +/** The edit mode: the bar with save. The tools under it are `EditingTools`. */ class EditActionModeCallback( private val activity: MainActivity, private val documentFragment: DocumentFragment, @@ -52,9 +49,8 @@ class EditActionModeCallback( override fun onDestroyActionMode(mode: ActionMode) { documentFragment.setEditing(false) - // the page keeps its edits with the mode off, so they are asked about here rather than - // thrown away. not when the document is being closed: that asked already, and the fragment - // is gone by the time the mode is finished + // the page keeps its edits with the mode off. not when the document is being closed: that + // asked already if (documentFragment.isAdded && documentFragment.hasUnsavedEdits()) { activity.confirmLeavingEdits { documentFragment.discardEdits() } } 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 86feb98419e9..e6c436616411 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 @@ -398,10 +398,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { load(DocumentRequest(uri, persistentUri).apply { this.editable = editable }) } - /** - * Turns the page's edit mode on or off. No render: a document the core can write back carries - * its editor from the start, so this is a switch in the page - see `PageView.setEditing`. - */ + /** Turns the page's edit mode on or off, without a render - see `PageView.setEditing`. */ fun setEditing(editing: Boolean) { // closeDocument() removes this fragment and only then finishes the edit mode if (!isAdded) { @@ -427,10 +424,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } } - /** - * Drops the edits the page holds by rendering the document again from the copy in the cache, - * which no edit reached. - */ + /** Drops the page's edits by rendering the cached copy again. */ fun discardEdits() { if (!isAdded || state.lastDocument == null) { return @@ -482,10 +476,10 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } override fun onCellsStale(count: Int) { - // said as it grows: an undo that brings it down needs no word + // said only as it grows val grew = count > staleCells staleCells = count - if (!grew) { + if (!grew || !isAdded) { return } @@ -550,14 +544,14 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } } - /** - * What an edit the page did not take says. The page gives a reason and an english message for a - * console; the wording a reader sees is ours. - */ + /** Says why the page refused an edit, in our words rather than the page's. */ private fun showRefusal(reason: String) { + if (!isAdded) { + return + } + if (reason == "outOfScope" && !Features.advancedEditing) { - // the one refusal pro answers. once an edit, so a page of refused line breaks is not a - // dialog each + // once per edit, not once per refused keystroke if (!proOfferedThisEdit) { proOfferedThisEdit = true @@ -709,8 +703,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { private fun prepareActions(document: LoadedDocument) { // 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. a pdf is marked up - // rather than edited, and says so - in lite too, where the button offers pro + // legacy binary formats nor the spreadsheets of issue #442 need naming val edit = when (document.editing) { EditingKind.NONE -> null @@ -899,9 +892,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { // before the page is put in below, so it is drawn the way it is going to stay applyDarkening(file) - // and put into the mode it is meant to be in once it has loaded: a save loads the written - // document back in the mode the old one was in. The kind has to be told either way, - // because it decides what leaving the mode does to the page + // the mode it is meant to be in, which a save carries over to the written document pageView?.setEditing(document.editing, document.request.editable) showEditingTools(document, document.request.editable) @@ -1457,7 +1448,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { /** Whether the document is in edit mode. */ fun isEditing(): Boolean = ::state.isInitialized && state.lastRequest?.editable == true - /** Whether the page holds edits or marks that are only in the page, which leaving loses. */ + /** Whether the page holds edits or marks no save has written. */ fun hasUnsavedEdits(): Boolean = ::state.isInitialized && state.editsDirty val editingKind: EditingKind 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 74dea81d82ef..efab7c0b8028 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 @@ -621,11 +621,10 @@ class MainActivity : AppCompatActivity() { DocumentActions.ACTION_EDIT -> { analyticsManager.report("menu_edit") - // the button stands on the core's answer, so in lite it is there over a pdf too, - // and says what pro would do with it + // the button follows the core, so lite shows it over a pdf and offers pro val kind = documentFragment?.editingKind ?: return if (!Features.offersEditing(kind)) { - offerPro(MainActivity.ProFeature.PDF) + offerPro(ProFeature.PDF) return } @@ -743,10 +742,7 @@ class MainActivity : AppCompatActivity() { ) } - /** - * Says that what was just tried is pro's, and leads to the pro listing. Lite is the only build - * that asks: pro and foss have every edit. - */ + /** Says that what was just tried is pro's, and leads to the pro listing. Lite only. */ fun offerPro(feature: ProFeature) { // the names OpenDocument.ios reports the same gate under analyticsManager.report("pro_gate_shown", "feature", feature.name.lowercase()) @@ -863,9 +859,7 @@ class MainActivity : AppCompatActivity() { SnackbarHelper.dismiss(this) } - // the fragment goes first: finishing an edit mode with edits in the page asks about them, - // and that question is about a document that is being closed. EditActionModeCallback only - // asks while the fragment is still added + // the fragment goes first, so finishing the edit mode does not ask about its edits again documentFragment?.let { fragment -> supportFragmentManager.beginTransaction().remove(fragment).commitNow() diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt index f3979b120932..223b7d0ef8c6 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt @@ -20,12 +20,8 @@ import app.opendocument.droid.R import org.json.JSONObject /** - * The strip of tools under the edit mode's bar: formatting for a text document or a presentation, - * the marking tools for a pdf, and undo and redo at the end of every one of them. The website's - * viewer is the reference, and OpenDocument.ios has the same row. - * - * It only reports taps. What a tool does to the page is the page's, through `PageView`, and which - * tool is on is what the page reports back - [setSelectionStyle] and [setArmedTool]. + * The tools under the edit mode's bar, with undo and redo at the end. It only reports taps; which + * tool is on comes back from the page through [setSelectionStyle] and [setArmedTool]. */ class EditingTools(context: Context, attributeSet: AttributeSet?) : HorizontalScrollView(context, attributeSet) { @@ -38,10 +34,7 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : /** State [style] on the selection, in the keys `odr.editing.format` takes. */ fun onFormat(style: JSONObject) - /** - * A marking tool was pressed, or picked a new [color] where [recolor] - see - * `odr.annotation.press` and `recolor` for what either does to a selection. - */ + /** A marking tool was pressed, or given a new [color] where [recolor]. */ fun onMarkTool(tool: String, @ColorInt color: Int, recolor: Boolean) /** A tool of pro's was tapped in a build without it. */ @@ -92,10 +85,7 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : visibility = View.GONE } - /** - * The formatting tools. [locked] puts pro's badge in front of them, and makes every one of them - * an offer of pro instead of an action. - */ + /** The formatting tools. [locked] adds pro's badge, and makes each tool offer pro. */ fun showFormatting(locked: Boolean) { reset(locked) @@ -251,8 +241,7 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : highlightTool?.isSelected = !locked && !style.isNull("highlight") - // the bars follow the selection, as the website's do; where the runs disagree they keep - // what they showed + // where the runs disagree, the bars keep what they showed style .optString("color") .takeIf { !style.isNull("color") } @@ -466,8 +455,7 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : /** Point sizes a document commonly uses. */ private val FONT_SIZES = listOf(8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48) - // the colors both apps offer, OpenDocument.ios' EditToolBar being the other copy. the - // first of each is the website's own default + // the same colors as OpenDocument.ios' EditToolBar; the first of each is the default private val TEXT_COLORS = listOf( NamedColor(0xff191c1e.toInt(), R.string.color_black), @@ -493,10 +481,7 @@ class EditingTools(context: Context, attributeSet: AttributeSet?) : NamedColor(0xff43a047.toInt(), R.string.color_green), ) - /** - * The five kinds of mark a pdf takes, in the annotator's own names, each with the colour it - * starts with: a wash for the highlighter, red for the three lines, blue ink for the pen. - */ + /** The marks a pdf takes, in the annotator's names, each with its starting colour. */ private val MARKS = listOf( Mark( 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 fcfa76fe8de3..d3991ebd1422 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 @@ -48,7 +48,7 @@ constructor(context: Context, attributeSet: AttributeSet?) : /** Told what the page's editor reports, on the main thread - see `editing-bridge.js`. */ var editingListener: EditingListener? = null - /** What [setEditing] was last told, which every page loaded after it is put into as well. */ + /** What [setEditing] was last told, applied again to every page that loads. */ private var editingKind = EditingKind.NONE private var isEditing = false @@ -457,10 +457,7 @@ constructor(context: Context, attributeSet: AttributeSet?) : } } - /** - * Turns the page's edit mode on or off. A pdf has no mode: its tools arm themselves, so leaving - * only disarms whatever tool is armed. - */ + /** Turns the page's edit mode on or off. A pdf has no mode, so leaving only disarms. */ fun setEditing(kind: EditingKind, editing: Boolean) { editingKind = kind isEditing = editing @@ -504,10 +501,7 @@ constructor(context: Context, attributeSet: AttributeSet?) : evaluateJavascript("odr.editing.format($style)", null) } - /** - * A marking tool was pressed, or [recolor] given a new colour - `odr.annotation.press` and - * `recolor` decide what that does to a selection. [callback] gets the tool left armed, or null. - */ + /** A marking tool pressed, or given a new colour; [callback] gets the tool left armed. */ fun pressMarkTool( tool: String, color: Int, @@ -529,10 +523,7 @@ constructor(context: Context, attributeSet: AttributeSet?) : } } - /** - * What a save hands the core: the page's operation log, or for a pdf the marks drawn over it. - * Null where the page could not say. - */ + /** What a save hands the core: the page's operations, or a pdf's marks. Null if none. */ fun requestEditPayload(kind: EditingKind, callback: (String?) -> Unit) { val expression = if (kind == EditingKind.ANNOTATION) { diff --git a/app/src/main/res/layout/fragment_document.xml b/app/src/main/res/layout/fragment_document.xml index e1dd8c184ca4..6398abe77643 100644 --- a/app/src/main/res/layout/fragment_document.xml +++ b/app/src/main/res/layout/fragment_document.xml @@ -10,10 +10,7 @@ android:layout_height="match_parent" android:orientation="vertical"> - + Date: Sat, 19 Sep 2026 10:55:21 +0200 Subject: [PATCH 7/7] Put the tool row above the ad banner In lite, the banner sat between the edit mode's bar and its tools, because the banner is in the activity layout and the tools were in the fragment. The tool row is now in main.xml, above the banner. DocumentFragment still drives it, and hides it when its view goes away. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3sFjPT7zd8fvmpqhznzEP --- .../droid/ui/activity/DocumentFragment.kt | 9 ++++++++- app/src/main/res/layout/fragment_document.xml | 8 -------- app/src/main/res/layout/main.xml | 14 +++++++++++++- 3 files changed, 21 insertions(+), 10 deletions(-) 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 e6c436616411..87c9d2ac8213 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 @@ -231,7 +231,8 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } actions.expandedListener = { expanded -> actionsBackCallback.isEnabled = expanded } - editingTools = view.findViewById(R.id.editing_tools) + // the activity's, so it sits above the banner - see main.xml + editingTools = mainActivity.findViewById(R.id.editing_tools) editingTools.listener = editingToolsListener // on viewLifecycleOwner, so it stacks above the activity's own callback - the dispatcher @@ -1463,6 +1464,12 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { override fun onDestroyView() { super.onDestroyView() + // the row outlives this view, and closing a document ends no edit mode here + if (::editingTools.isInitialized) { + editingTools.hide() + editingTools.listener = null + } + if (::documentLoader.isInitialized) { documentLoader.listener = null } diff --git a/app/src/main/res/layout/fragment_document.xml b/app/src/main/res/layout/fragment_document.xml index 6398abe77643..6df641e7347a 100644 --- a/app/src/main/res/layout/fragment_document.xml +++ b/app/src/main/res/layout/fragment_document.xml @@ -10,14 +10,6 @@ android:layout_height="match_parent" android:orientation="vertical"> - - - + + +