diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index 2f62d5f8de6e..714274db142c 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -161,6 +161,20 @@ jobs: max_attempts: 3 command: ./gradlew packagePlayRelease packagePlayReleaseAndroidTest --daemon + - name: Install Android Emulator + # Retry to work around https://github.com/ReactiveCircus/android-emulator-runner/issues/496 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 5 + retry_wait_seconds: 60 + max_attempts: 3 + command: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --verbose --install emulator --channel=0' + # sdkmanager preserves failed downloads and installation state for resuming. + on_retry_command: >- + rm -rf "${ANDROID_HOME:?}/.temp" + "${ANDROID_HOME:?}/.downloadIntermediates" + "${ANDROID_HOME:?}/emulator/.installer" + - name: AVD Boot and Snapshot Creation # Only generate a snapshot for saving if we are on main branch with a cache miss # Comment the if out to generate snapshots on branch for performance testing diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 4c106abb254a..767bc2fa7773 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -118,7 +118,17 @@ android { // // This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is // needed for upgrades to be offered correctly. - versionCode=22600100 + versionCode=22600101 + // If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version... + versionName="2.26.0alpha1" + minSdk = libs.versions.minSdk.get().toInteger() + + // After #13695: change .tests_emulator.yml + targetSdk = libs.versions.targetSdk.get().toInteger() + testApplicationId = "com.ichi2.anki.tests" + vectorDrawables.useSupportLibrary = true + testInstrumentationRunner = 'com.ichi2.testutils.NewCollectionPathTestRunner' + // Quest tags have their own monotonic upgrade sequence, independent of // the upstream beta version. Keep the eight-digit ABI-split budget. // quest- is 5nnnn000; nightly builds after it add their commit count, @@ -137,15 +147,6 @@ android { setVersionCode(50000000 + questRelease.toInteger() * 1000 + questNightly.toInteger()) } } - // If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version... - versionName="2.26.0alpha0" - minSdk = libs.versions.minSdk.get().toInteger() - - // After #13695: change .tests_emulator.yml - targetSdk = libs.versions.targetSdk.get().toInteger() - testApplicationId = "com.ichi2.anki.tests" - vectorDrawables.useSupportLibrary = true - testInstrumentationRunner = 'com.ichi2.testutils.NewCollectionPathTestRunner' // GA4 Measurement Protocol api_secret. Sourced from `local.properties` // (ANALYTICS_API_KEY) or the ANALYTICS_API_KEY env var; falls back to @@ -687,6 +688,15 @@ apply from: "./robolectricDownloader.gradle" apply from: "./jacoco.gradle.kts" apply from: "../lint.gradle" +android { + lint { + // Analyze project-local dependencies (:common:android etc.) as part of this + // module's lint run. + // Required for whole-app UnusedResources and StringFormatMatches coverage + checkDependencies = true + } +} + dependencies { configurations.configureEach { resolutionStrategy { diff --git a/AnkiDroid/jacoco.gradle.kts b/AnkiDroid/jacoco.gradle.kts index 9f5fec6b4610..24bcd95e4978 100644 --- a/AnkiDroid/jacoco.gradle.kts +++ b/AnkiDroid/jacoco.gradle.kts @@ -89,6 +89,25 @@ fun builtInKotlincClassDir(variant: String): String { val flavorClassDir = builtInKotlincClassDir("play${rootProject.androidTestVariantName}") val moduleClassDir = builtInKotlincClassDir(rootProject.androidTestVariantName.lowercase()) +// Narrow coverage roots: a fileTree over the whole build/ dir makes Gradle treat every +// sibling task output under build/ as an undeclared input (e.g. lintFullDebug + this report). +val unitTestCoverageDirs = listOf("outputs/unit_test_code_coverage", "jacoco") +val androidTestCoverageDirs = listOf("outputs/code_coverage") + +fun Project.coverageFileTree( + relativeDirs: List, + includes: List, +) = files( + relativeDirs.map { relativeDir -> + fileTree( + mapOf( + "dir" to layout.buildDirectory.dir(relativeDir), + "includes" to includes, + ), + ) + }, +) + // Our merge report task tasks.register("jacocoTestReport") { val htmlOutDir = @@ -120,12 +139,8 @@ tasks.register("jacocoTestReport") { sourceDirectories.setFrom(project.files(mainSrc)) classDirectories.setFrom(project.files(kotlinClasses)) executionData.setFrom( - project.fileTree( - mapOf( - "dir" to project.layout.buildDirectory, - "includes" to listOf("**/*.exec", "**/*.ec"), - ), - ), + project.coverageFileTree(unitTestCoverageDirs, listOf("**/*.exec")), + project.coverageFileTree(androidTestCoverageDirs, listOf("**/*.ec")), ) dependsOn("testPlayDebugUnitTest") dependsOn("connectedPlay${rootProject.androidTestVariantName}AndroidTest") @@ -208,16 +223,7 @@ fun includeUnitTestCoverage( ), ), ) - report.executionData.from( - project.files( - project.fileTree( - mapOf( - "dir" to project.layout.buildDirectory, - "includes" to listOf("**/*.exec"), - ), - ), - ), - ) + report.executionData.from(project.coverageFileTree(unitTestCoverageDirs, listOf("**/*.exec"))) } // A connected android tests only report task @@ -250,14 +256,7 @@ tasks.register("jacocoAndroidTestReport") { sourceDirectories.setFrom(project.files(mainSrc)) classDirectories.setFrom(project.files(kotlinClasses)) - executionData.setFrom( - project.fileTree( - mapOf( - "dir" to project.layout.buildDirectory, - "includes" to listOf("**/*.ec"), - ), - ), - ) + executionData.setFrom(project.coverageFileTree(androidTestCoverageDirs, listOf("**/*.ec"))) dependsOn("connectedPlay${rootProject.androidTestVariantName}AndroidTest") } diff --git a/AnkiDroid/robolectricDownloader.gradle b/AnkiDroid/robolectricDownloader.gradle index 83756ce8ed29..f70b83d96177 100644 --- a/AnkiDroid/robolectricDownloader.gradle +++ b/AnkiDroid/robolectricDownloader.gradle @@ -1,5 +1,6 @@ /** - * Downloads all android-all-instrumented dependencies and copies them to the mavenLocal() repository + * Downloads android-all-instrumented dependencies into Gradle's cache for offline Robolectric. + * Writes a properties file pointing to the cached jars, so worktrees do not need their own copies. * * Once applied to your gradle project, can be executed with ./gradlew robolectricSdkDownload */ @@ -13,11 +14,8 @@ // Note: the PREINSTRUMENTED_VERSION constant is what you put on the end of the artifact // Only the versions currently used in AnkiDroid Robolectric tests are active, the rest are commented out -// To update these versions, open a terminal in the Anki-Android directory and perform the following steps: -// 1. Run `rm -r ~/.m2` to delete the .m2 directory in your home directory. -// 2. Run `./gradlew jacocoUnitTestReport` to run all unit tests. -// 3. Run `find ~/.m2 -type d -name '*-robolectric-*'`. -// 4. Update the lines below to match the output from `find`. +// When updating Robolectric, match these versions to DefaultSdkProvider and PREINSTRUMENTED_VERSION. +// Run `./gradlew jacocoUnitTestReport` to check that all SDKs used by the tests are included. def robolectricAndroidSdkVersions = [ // [androidVersion: "6.0.1_r3", frameworkSdkBuildVersion: "r1"], // [androidVersion: "7.0.0_r1", frameworkSdkBuildVersion: "r1"], @@ -36,13 +34,13 @@ def robolectricAndroidSdkVersions = [ [androidVersion: "16", frameworkSdkBuildVersion: "13921718"], // current targetSdk ] -// Base, public task - will be displayed in ./gradlew robolectricDownloader:tasks -tasks.register('robolectricSdkDownload') { +def robolectricSdkDownload = tasks.register('robolectricSdkDownload', WriteProperties) { group = "Dependencies" - description = "Downloads all robolectric SDK dependencies into mavenLocal, for use with offline robolectric" + description = "Downloads Robolectric SDKs and writes their cached paths for offline tests" + destinationFile = layout.buildDirectory.file("robolectric-deps.properties") } -// Generate the configuration and actual copy tasks. +// Use separate configurations so Gradle retains every SDK version instead of selecting the newest. robolectricAndroidSdkVersions.forEach { robolectricSdkVersion -> // the final part of this `-i` comes from PREINSTRUMENTED_VERSION in upstream DefaultSdkProvider def version = "${robolectricSdkVersion['androidVersion']}-robolectric-${robolectricSdkVersion['frameworkSdkBuildVersion']}-i7" @@ -52,24 +50,10 @@ robolectricAndroidSdkVersions.forEach { robolectricSdkVersion -> def sdkConfig = configurations.create(version) dependencies.add(version, "org.robolectric:android-all-instrumented:${version}") - // An ArtifactView on the existing configuration gives us the POM alongside the JAR so - // maven local has both. - def sdkPomFiles = sdkConfig.incoming.artifactView { - attributes { - attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, "pom") - } - // if the POM can't be resolved, return an empty collection rather than failing the build - lenient = true - }.files - - def mavenLocalFile = new File(this.repositories.mavenLocal().url) - def mavenRobolectric = new File(mavenLocalFile, "org/robolectric/android-all-instrumented/${version}") - // Copying all files downloaded for the created configuration into maven local. - tasks.register("robolectricSdkDownload-${version}", Copy) { - from sdkConfig - from sdkPomFiles - into mavenRobolectric + robolectricSdkDownload.configure { + inputs.files(sdkConfig).withPropertyName(version) + property("org.robolectric:android-all-instrumented:${version}", + sdkConfig.elements.map { it.iterator().next().asFile.absolutePath }) } - robolectricSdkDownload.configure { dependsOn "robolectricSdkDownload-${version}" } } diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt index 26e2eb0bbb76..5ebff4bc3edd 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt @@ -4,6 +4,7 @@ package com.ichi2.anki.pages import androidx.core.content.edit import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope import androidx.test.core.app.ActivityScenario import androidx.test.platform.app.InstrumentationRegistry import com.ichi2.anki.CollectionManager.TR @@ -15,6 +16,8 @@ import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.testutil.waitUntil import com.ichi2.testutils.ext.defaultDeckNewCardsPerDay import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.json.JSONObject @@ -71,38 +74,116 @@ class DeckOptionsTest : InstrumentedTest() { fun optimizingAllPresetsSavesAndReloadsOptions() { withDeckOptions { saveAndOptimize(newPerDay = 43) + assertSavedAndReloaded(newPerDay = 43) + } + } + + @Test + fun optimizingAllPresetsWaitsForSlowSave() { + withDelayedSave { + saveAndOptimize(newPerDay = 43) + assertSavedAndReloaded(newPerDay = 43) + } + } - waitUntil(timeout = 30.seconds, message = { "optimization did not save the changed limit" }) { - col.defaultDeckNewCardsPerDay == 43 + @Test + fun failedOptimizationKeepsEditsAndAllowsRetry() { + val originalLimit = col.defaultDeckNewCardsPerDay + withDelayedSave(failFirstAttempt = true) { + // Capture the error without leaving a dialog blocking the retry. + evaluateJavascript("window.alert = message => { globalThis.saveError = String(message); }") + saveAndOptimize(newPerDay = 43) + + waitForPageCondition( + condition = "globalThis.saveError?.includes('Optimization failed for test') === true", + message = "save error was not displayed", + ) + assertEquals("true", evaluateJavascript("globalThis.beforeOptimization === true"), "failed save reloaded the page") + assertEquals(originalLimit, col.defaultDeckNewCardsPerDay) + + saveAndOptimize(newPerDay = 43) + assertSavedAndReloaded(newPerDay = 43) + } + } + + /** Exercise a pending response, optionally rejecting the first save so the test can retry. */ + private fun withDelayedSave( + failFirstAttempt: Boolean = false, + block: DeckOptions.() -> Unit, + ) { + val originalHandler = uiMethods.getValue("updateDeckConfigs") + var failNextSave = failFirstAttempt + uiMethods["updateDeckConfigs"] = { input -> + val activity = this + lifecycleScope.async { + // explicitly reproduce the reload race + delay(1.seconds) + if (failNextSave) { + failNextSave = false + error("Optimization failed for test") + } + originalHandler.invoke(activity, input).await() } - val optionsReloaded = AtomicBoolean(false) - waitUntil(timeout = 30.seconds, message = { "options did not reload after optimization" }) { - // Reloading can discard an evaluation callback. Retry without waiting for each one. - InstrumentationRegistry.getInstrumentation().runOnMainSync { - webViewLayout.evaluateJavascript( - "globalThis.beforeOptimization === undefined && Array.from(document.querySelectorAll('input[type=number]')).find(input => input.offsetParent !== null)?.value === '43'", - ) { - if (it == "true") optionsReloaded.set(true) - } + } + try { + withDeckOptions(block) + } finally { + uiMethods["updateDeckConfigs"] = originalHandler + } + } + + private fun DeckOptions.assertSavedAndReloaded(newPerDay: Int) { + waitUntil(timeout = 30.seconds, message = { "optimization did not save the changed limit" }) { + col.defaultDeckNewCardsPerDay == newPerDay + } + + // The edited field already has the expected value. The marker disappearing proves + // that a new document was loaded and the value came back from the saved settings. + waitForPageCondition( + condition = + """ + (() => { + const newCardsPerDay = Array.from(document.querySelectorAll('input[type="number"]')) + .find(input => input.offsetParent !== null); + return globalThis.beforeOptimization === undefined && newCardsPerDay?.value === '$newPerDay'; + })(); + """.trimIndent(), + message = "options did not reload after optimization", + ) + assertFalse(requireActivity().isFinishing) + } + + /** Poll across page reloads, which can discard an individual JavaScript evaluation callback. */ + private fun DeckOptions.waitForPageCondition( + condition: String, + message: String, + ) { + val satisfied = AtomicBoolean(false) + waitUntil(timeout = 30.seconds, message = { message }) { + // Reloading can discard an evaluation callback. Retry without waiting for each one. + InstrumentationRegistry.getInstrumentation().runOnMainSync { + webViewLayout.evaluateJavascript(condition) { + if (it == "true") satisfied.set(true) } - optionsReloaded.get() } - assertFalse(requireActivity().isFinishing) + satisfied.get() } } private fun DeckOptions.saveAndOptimize(newPerDay: Int) { val script = """ - globalThis.beforeOptimization = true; - window.confirm = () => true; - const input = Array.from(document.querySelectorAll('input[type="number"]')).find(input => input.offsetParent !== null); - input.focus(); - input.value = '$newPerDay'; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - Array.from(document.querySelectorAll('button')) - .find(button => button.textContent.trim() === ${JSONObject.quote(TR.deckConfigSaveAndOptimize())}).click(); + (() => { + globalThis.beforeOptimization = true; + window.confirm = () => true; + const input = Array.from(document.querySelectorAll('input[type="number"]')).find(input => input.offsetParent !== null); + input.focus(); + input.value = '$newPerDay'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + Array.from(document.querySelectorAll('button')) + .find(button => button.textContent.trim() === ${JSONObject.quote(TR.deckConfigSaveAndOptimize())}).click(); + })(); """.trimIndent() // The action navigates away from its JavaScript context, which can discard the // evaluation callback. Observe the persisted settings and the new page instead. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index 67c448947a0d..2673355c8004 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -1061,6 +1061,8 @@ abstract class AbstractFlashcardViewer : MyWebView(this).apply { scrollBarStyle = View.SCROLLBARS_OUTSIDE_OVERLAY with(settings) { + // Scale the whole card with CSS instead; text-only zoom breaks MathJax layout. + textZoom = 100 displayZoomControls = false builtInZoomControls = true setSupportZoom(true) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt index 03a94c8c82fe..3c9ed3d2bfda 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt @@ -93,7 +93,9 @@ class AndroidTtsPlayer( TtsVoices.ttsEngine?.let { defaultEngine -> getOrCreateTts(defaultEngine) } } - override fun getAvailableVoices(): List = this.voices + // Prefer installed voices for language fallback without changing the shared matcher. + // Keep uninstalled voices selectable: an explicit request can trigger their download. + override fun getAvailableVoices(): List = voices.sortedBy { it.unavailable() } /** * Returns a ready [TextToSpeech] bound to [engine], creating and caching one if necessary. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index 8a2691a03cac..b9577f959eb4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -115,7 +115,7 @@ open class AnkiActivity( ) : AppCompatActivity(contentLayoutId ?: 0), ShortcutGroupProvider, AnkiActivityProvider { - val exportReadyViewModel by viewModels() + val exportReadyViewModel by viewModels { ExportReadyViewModel.factory } /** * Receiver that informs us when a broadcast listen in [broadcastsActions] is received. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt index c91ade3ac086..b985b5572da2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt @@ -6,7 +6,10 @@ import android.content.Context import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import com.ichi2.widget.WidgetStatus +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch import timber.log.Timber class AppLifecycleObserver( @@ -15,9 +18,17 @@ class AppLifecycleObserver( override fun onStop(owner: LifecycleOwner) { super.onStop(owner) - if (owner.lifecycle.currentState != Lifecycle.State.DESTROYED && CollectionManager.isOpenUnsafe()) { + if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) return + + // A sync may hold the collection queue. Keep the main thread free to handle + // WorkManager's foreground service timeout while waiting for the collection. + owner.lifecycleScope.launch { try { - WidgetStatus.updateInBackground(context) + if (CollectionManager.withOpenColOrNull { true } == true) { + WidgetStatus.updateInBackground(context) + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.w(e) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 132aa3c4f565..ca00f223fd6a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -64,6 +64,7 @@ import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding +import androidx.core.widget.doAfterTextChanged import androidx.draganddrop.DropHelper import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -74,6 +75,7 @@ import anki.notes.NoteFieldsCheckResponse import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputLayout import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.NoteEditorFragment.Companion.NoteEditorCaller.Companion.fromValue @@ -2426,18 +2428,66 @@ class NoteEditorFragment : requireContext().openUrl(R.string.link_manual_note_format_toolbar) }.negativeButton(CommonString.dialog_cancel) + @NeedsTest("Create button disabled until name + one HTML field are filled") private fun displayAddToolbarDialog() { val v = layoutInflater.inflate(R.layout.dialog_note_editor_toolbar_add_custom_item, null) - toolbarDialog.show { - title(CommonString.add_toolbar_item) - setView(v) - positiveButton(CommonString.dialog_positive_create) { - val etIcon = v.findViewById(R.id.note_editor_toolbar_item_icon) - val et = v.findViewById(R.id.note_editor_toolbar_before) - val et2 = v.findViewById(R.id.note_editor_toolbar_after) - addToolbarButton(etIcon.text.toString(), et.text.toString(), et2.text.toString()) + + val etIcon = v.findViewById(R.id.note_editor_toolbar_item_icon) + val etBefore = v.findViewById(R.id.note_editor_toolbar_before) + val etAfter = v.findViewById(R.id.note_editor_toolbar_after) + + val etIconLayout = v.findViewById(R.id.note_editor_toolbar_item_icon_layout) + val etBeforeLayout = v.findViewById(R.id.note_editor_toolbar_before_layout) + val etAfterLayout = v.findViewById(R.id.note_editor_toolbar_after_layout) + + // Both HTML fields share one error message, displayed under etAfterLayout only. + // etBeforeLayout still receives the message so accessibility services report the field as + // invalid, but its text is hidden and it is not a live region, which would announce the message twice + etBeforeLayout.setErrorTextAppearance(R.style.TextAppearance_AnkiDroid_HiddenError) + etBeforeLayout.errorAccessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_NONE + + val dialog = + toolbarDialog.show { + title(CommonString.add_toolbar_item) + setView(v) + positiveButton(CommonString.dialog_positive_create) { + addToolbarButton(etIcon.text.toString(), etBefore.text.toString(), etAfter.text.toString()) + } } + + val posButton = dialog.positiveButton + posButton.isEnabled = false + + // Setting an unchanged error would re-announce it to screen readers on every keystroke + fun TextInputLayout.setErrorIfChanged(message: String) { + if (error == message) return + error = message } + + fun updateToolbarItemValidationState() { + val hasIcon = etIcon.text.toString().isNotBlank() + val hasHtml = etBefore.text.toString().isNotBlank() || etAfter.text.toString().isNotBlank() + posButton.isEnabled = hasIcon && hasHtml + + if (hasIcon) { + etIconLayout.isErrorEnabled = false + } else { + etIconLayout.setErrorIfChanged(getString(R.string.note_editor_toolbar_icon_requirement_error)) + } + + if (hasHtml) { + etBeforeLayout.isErrorEnabled = false + etAfterLayout.isErrorEnabled = false + } else { + val message = getString(R.string.before_after_text_requirement_error) + etBeforeLayout.setErrorIfChanged(message) + etAfterLayout.setErrorIfChanged(message) + } + } + + etIcon.doAfterTextChanged { updateToolbarItemValidationState() } + etBefore.doAfterTextChanged { updateToolbarItemValidationState() } + etAfter.doAfterTextChanged { updateToolbarItemValidationState() } } private fun displayEditToolbarDialog(currentButton: CustomToolbarButton) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt index b24855f06423..4ae9410e5dd8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt @@ -22,6 +22,7 @@ import androidx.core.os.BundleCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout +import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding @@ -103,7 +104,7 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.activity_note_type_field_edito enableToolbar() enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)) ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> - val constraints = insets.getInsets(systemBars() or displayCutout()) + val constraints = insets.getInsets(systemBars() or displayCutout() or ime()) binding.toolbarContainer.updatePadding(left = constraints.left, right = constraints.right, top = constraints.top) binding.btnAdd.updateLayoutParams { bottomMargin = constraints.bottom + 32.dp.toPx(this@NoteTypeFieldEditor) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index c4f18b8e9b03..dbfbab4c7e60 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -24,6 +24,7 @@ import androidx.core.text.parseAsHtml import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout +import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.doOnAttach import androidx.core.view.isVisible @@ -125,13 +126,13 @@ class StudyOptionsFragment : refreshInterface() } - /** Keeps the content clear of the system bars and any display cutout. */ + /** Keeps the content clear of the system bars, display cutout and keyboard. */ private fun setupContentInsets(view: View) { view.doOnApplyWindowInsets { v, insets, _ -> // Both hosts display this fragment below a toolbar, so don't update the top padding. // Note: the left-padding is 0 when fragmented - handled by DeckPicker - val bars = insets.getInsets(systemBars() or displayCutout()) - v.updatePadding(left = bars.left, right = bars.right, bottom = bars.bottom) + val contentInsets = insets.getInsets(systemBars() or displayCutout() or ime()) + v.updatePadding(left = contentInsets.left, right = contentInsets.right, bottom = contentInsets.bottom) } // the view may be added after the insets were dispatched (returning from the review // reminders screen), so request them again diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index e76394367369..64c688288e22 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -3,7 +3,6 @@ package com.ichi2.anki.browser import android.os.Bundle -import android.os.Parcel import android.os.Parcelable import androidx.annotation.CheckResult import androidx.core.os.BundleCompat @@ -95,11 +94,7 @@ import kotlinx.parcelize.Parcelize import net.ankiweb.rsdroid.BackendException import org.jetbrains.annotations.VisibleForTesting import timber.log.Timber -import java.io.DataInputStream -import java.io.DataOutputStream import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream import java.util.Collections import kotlin.math.max import kotlin.math.min @@ -1744,70 +1739,6 @@ enum class CardBrowserProgress { DELETING_NOTES, } -/** - * Temporary file containing cards or note IDs to be passed in a Bundle. - * - * It avoids [android.os.TransactionTooLargeException] when passing a big amount of data. - */ -class IdsFile( - path: String, -) : File(path), - Parcelable { - /** - * @param directory parent directory of the file. Generally it should be the cache directory - * @param ids ids to store - */ - constructor(directory: File, ids: List, prefix: String = "ids") : this(path = createTempFile(prefix, ".tmp", directory).path) { - DataOutputStream(FileOutputStream(this)).use { outputStream -> - outputStream.writeInt(ids.size) - for (id in ids) { - outputStream.writeLong(id) - } - } - } - - fun getIds(): List = - DataInputStream(FileInputStream(this)).use { inputStream -> - val size = inputStream.readInt() - List(size) { inputStream.readLong() } - } - - override fun describeContents(): Int = 0 - - override fun writeToParcel( - dest: Parcel, - flags: Int, - ) { - dest.writeString(path) - } - - companion object { - @JvmField - @Suppress("unused") - val CREATOR = - object : Parcelable.Creator { - override fun createFromParcel(source: Parcel?): IdsFile = IdsFile(source!!.readString()!!) - - override fun newArray(size: Int): Array = arrayOf() - } - } -} - -/** Attempt to delete the associated [IdsFile] and logs the result */ -fun IdsFile.removeSafely(owner: String) { - runCatching { delete() } - .onFailure { throwable -> - Timber.w( - throwable, - "Exception when removing IdsFile of $owner", - ) - }.onSuccess { status -> - Timber.i( - "$owner associated IdsFile was deleted: $status", - ) - } -} - /** * Determines if a card can be repositioned. * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt new file mode 100644 index 000000000000..5b3f90c879a4 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser + +import android.os.Parcel +import android.os.Parcelable +import timber.log.Timber +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * Temporary file containing cards or note IDs to be passed in a Bundle. + * + * It avoids [android.os.TransactionTooLargeException] when passing a big amount of data. + */ +class IdsFile( + path: String, +) : File(path), + Parcelable { + /** + * @param directory parent directory of the file. Generally it should be the cache directory + * @param ids ids to store + */ + constructor(directory: File, ids: List, prefix: String = "ids") : this(path = createTempFile(prefix, ".tmp", directory).path) { + DataOutputStream(FileOutputStream(this)).use { outputStream -> + outputStream.writeInt(ids.size) + for (id in ids) { + outputStream.writeLong(id) + } + } + } + + fun getIds(): List = + DataInputStream(FileInputStream(this)).use { inputStream -> + val size = inputStream.readInt() + List(size) { inputStream.readLong() } + } + + override fun describeContents(): Int = 0 + + override fun writeToParcel( + dest: Parcel, + flags: Int, + ) { + dest.writeString(path) + } + + companion object { + @JvmField + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator { + override fun createFromParcel(source: Parcel?): IdsFile = IdsFile(source!!.readString()!!) + + override fun newArray(size: Int): Array = arrayOf() + } + } +} + +/** Attempt to delete the associated [IdsFile] and logs the result */ +fun IdsFile.removeSafely(owner: String) { + runCatching { delete() } + .onFailure { throwable -> + Timber.w( + throwable, + "Exception when removing IdsFile of $owner", + ) + }.onSuccess { status -> + Timber.i( + "$owner associated IdsFile was deleted: $status", + ) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt index a0f007eac77e..ad3a9b2bf769 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt @@ -118,7 +118,7 @@ class AndroidCardRenderContext( typeAnswer: TypeAnswer, ): AndroidCardRenderContext { val preferences = context.sharedPrefs() - val cardAppearance = CardAppearance.create(ReviewerCustomFonts(), preferences) + val cardAppearance = CardAppearance.create(ReviewerCustomFonts(), preferences, context.resources.configuration.fontScale) val cardHtmlTemplate = CardTemplate.load(context) val showAudioPlayButtons = !col.config.getBool(ConfigKey.Bool.HIDE_AUDIO_PLAY_BUTTONS) return AndroidCardRenderContext( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt index 32598206120e..f72fb69eebd7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt @@ -15,9 +15,15 @@ class CardAppearance( private val cardZoom: Int, private val imageZoom: Int, private val centerVertically: Boolean, + private val fontScale: Float, ) { /** Below could be in a better abstraction. */ fun appendCssStyle(style: StringBuilder) { + // Preserve the system font size while scaling text and em-based layout together. + if (fontScale != 1f) { + style.append("html { zoom: $fontScale }\n") + } + // Zoom cards if (cardZoom != 100) { style.append("body { zoom: ${cardZoom / 100.0} }\n") @@ -68,11 +74,12 @@ class CardAppearance( fun create( customFonts: ReviewerCustomFonts, preferences: SharedPreferences, + fontScale: Float, ): CardAppearance { val cardZoom = preferences.getInt("cardZoom", 100) val imageZoom = preferences.getInt("imageZoom", 100) val centerVertically = preferences.getBoolean("centerVertically", false) - return CardAppearance(customFonts, cardZoom, imageZoom, centerVertically) + return CardAppearance(customFonts, cardZoom, imageZoom, centerVertically, fontScale) } fun fixBoldStyle(content: String): String { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt index 05fd97a54ad8..98a1eca274b6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt @@ -56,6 +56,13 @@ open class OnRenderProcessGoneDelegate( Timber.e("WebView Renderer process terminated. Crashed: %b", detail.didCrash()) target.destroyWebViewFrame() + // #6244: finish() can invalidate the window before the lifecycle drops below STARTED. + if (target.isFinishing || target.isDestroyed) { + // don't show a dialog in this case + Timber.w("Activity is finishing or destroyed; skipping WebView recovery/crash dialog") + return true + } + // Only show one message per branch when { !canRecoverFromWebViewRendererCrash() -> { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt index 24bab21cc278..0e4d436bc7ff 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt @@ -5,6 +5,7 @@ package com.ichi2.anki.dialogs.tags import android.app.Dialog import android.content.Context import android.content.DialogInterface +import android.content.res.Configuration import android.os.Bundle import android.os.Parcelable import android.text.InputFilter @@ -12,6 +13,7 @@ import android.text.InputType import android.text.Spanned import android.view.MenuItem import android.view.View +import android.view.Window import android.view.WindowManager import android.widget.EditText import android.widget.RadioGroup @@ -250,9 +252,7 @@ class TagsDialog : AnalyticsDialogFragment { positiveButton?.isEnabled = true } - dialog.window?.let { - resizeWhenSoftInputShown(it) - } + dialog.window?.updateSoftInputMode(resources.configuration) return dialog } @@ -291,6 +291,19 @@ class TagsDialog : AnalyticsDialogFragment { dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + dialog?.window?.updateSoftInputMode(newConfig) + } + + private fun Window.updateSoftInputMode(configuration: Configuration) { + if (configuration.screenHeightDp < COMPACT_HEIGHT_DP) { + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + } else { + resizeWhenSoftInputShown(this) + } + } + private fun radioButtonIdToCardState(id: Int) = when (id) { 0 -> CardStateFilter.ALL_CARDS @@ -471,6 +484,7 @@ class TagsDialog : AnalyticsDialogFragment { const val ARG_TAGS_FILE = "tagsFile" private const val ARG_DIALOG_TYPE = "dialogType" private const val ARG_CHECKED_TAGS = "checkedTags" + private const val COMPACT_HEIGHT_DP = 480 /** * The filter that constrains the inputted tag. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt index 01234d4b6799..ca3fe06b903d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt @@ -3,10 +3,11 @@ package com.ichi2.anki.dialogs.viewmodel import android.os.Parcelable -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.ichi2.anki.AnkiActivity import com.ichi2.anki.dialogs.ExportReadyDialog +import com.ichi2.anki.utils.ViewModelSavedStateHandle +import com.ichi2.anki.utils.savedStateViewModelFactory import kotlinx.coroutines.flow.StateFlow import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -18,7 +19,7 @@ import timber.log.Timber * @see AnkiActivity */ class ExportReadyViewModel( - private val savedStateHandle: SavedStateHandle, + private val savedStateHandle: ViewModelSavedStateHandle, ) : ViewModel() { val exportReadyDestination: StateFlow field = savedStateHandle.getMutableStateFlow(ARG_EXPORT_READY_PARAMS, null) @@ -41,6 +42,8 @@ class ExportReadyViewModel( ) : Parcelable companion object { + val factory = savedStateViewModelFactory(create = ::ExportReadyViewModel) + private const val ARG_EXPORT_READY_PARAMS = "arg_export_ready_params" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt index 001cbc7ed6cf..0ee56925dbb1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt @@ -35,6 +35,7 @@ import com.ichi2.anki.withProgress import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber +import java.util.concurrent.atomic.AtomicInteger @NeedsTest("15130: pressing back: icon + button should return to options if the manual is open") @NeedsTest("17905: pressing back before the webpage is ready closes the screen") @@ -46,6 +47,7 @@ class DeckOptions : PageFragment() { "deck-options/$deckId" } private var webViewIsReady = false + private val saveAndOptimizeReload = SaveAndOptimizeReload() /** * Callback enabled when the manual is opened in the deck options. @@ -164,6 +166,7 @@ class DeckOptions : PageFragment() { override fun onWebViewCreated() { // addJavascriptInterface needs to happen before loadUrl webViewLayout.addJavascriptInterface(ModalJavaScriptInterfaceListener(), "ankidroid") + webViewLayout.addJavascriptInterface(saveAndOptimizeReload, "ankidroidSave") Timber.d("Added JS Interface: 'ankidroid") } @@ -188,8 +191,9 @@ class DeckOptions : PageFragment() { ): Boolean { // #16715: ensure that the fragment can't be used for general web browsing val host = request?.url?.host ?: return shouldOverrideUrlLoading(view, request) - // Allow JavaScript to reload this page, including when night mode adds a fragment. - if (request.url.toString().substringBefore('#') == view?.url?.substringBefore('#')) return false + if (request.url.toString().substringBefore('#') == view?.url?.substringBefore('#')) { + return saveAndOptimizeReload.deferReloadIfSaving() + } return if (ankiManualHostRegex.matches(host)) { super.shouldOverrideUrlLoading(view, request) } else { @@ -278,29 +282,118 @@ class DeckOptions : PageFragment() { webViewIsReady = true webViewLayout.isVisible = true pageLoadingIndicator.isVisible = false - trackSaveCompletion() + saveAndOptimizeReload.onPageReady() setParameterUnlockClickTimeout() } - /** Track receipt of the full save response, so reloading cannot abort it. */ - private fun trackSaveCompletion() { - webViewLayout.evaluateJavascript( - """ - (() => { - const originalFetch = window.fetch; - window.fetch = (input, init) => { - const response = originalFetch(input, init); - if (input !== "/_anki/updateDeckConfigs") return response; - const completed = response.then(async (response) => { - await response.clone().arrayBuffer(); + /** Request a reload after Save & Optimize's response has reached the page. Called on main. */ + fun reloadAfterSave() { + saveAndOptimizeReload.requestReload() + } + + /** + * AnkiDroid workaround for the frontend reloading before its save response has been read. + * + * 1. The fetch wrapper calls [started] before the frontend can request a reload. + * 2. [requestReload] schedules navigation; [deferReloadIfSaving] holds premature frontend reloads. + * 3. Reading the response body calls [finished]: success releases the reload; failure cancels it. + * + * Remove this when the bundled frontend awaits state.save() before reloading. + */ + inner class SaveAndOptimizeReload { + // JavaScript bridge calls run on a WebView thread; navigation callbacks run on main. + private val pendingSaves = AtomicInteger() + private var reloadRequested = false // Accessed only on the main thread. + + fun onPageReady() { + pendingSaves.set(0) + reloadRequested = false + trackSaveResponse() + } + + @JavascriptInterface + fun started() { + // Do this synchronously: posting to main could let the reload arrive first. + pendingSaves.incrementAndGet() + } + + /** Called on main. Returning true keeps the current document and its request alive. */ + fun deferReloadIfSaving(): Boolean { + if (pendingSaves.get() == 0) return false + requestReload() + return true + } + + fun requestReload() { + reloadRequested = true + } + + @JavascriptInterface + fun finished(success: Boolean) { + pendingSaves.decrementAndGet() + launchCatchingTask { + if (!success) { + // Keep the edited form on screen so the user can retry. + reloadRequested = false + return@launchCatchingTask + } + if (pendingSaves.get() != 0 || !reloadRequested || view == null) return@launchCatchingTask + + reloadRequested = false + webViewLayout.reload() + } + } + + /** + * Tracks the HTTP body read, not completion of frontend decoding or state.save(). + * + * The native handler finishes saving before returning its response. Waiting for blob() + * also avoids interrupting the frontend's network body read when we reload. Subsequent + * decoding is not awaited: the new page reads the saved settings from the backend. + */ + private fun trackSaveResponse() { + webViewLayout.evaluateJavascript( + """ + (() => { + const originalFetch = window.fetch; + + async function readSaveBody(readBlob) { + try { + const body = await readBlob(); + ankidroidSave.finished(true); + return body; + } catch (error) { + ankidroidSave.finished(false); + throw error; // Leave error reporting to the frontend. + } + } + + window.fetch = async (input, init) => { + if (input !== "/_anki/updateDeckConfigs") return originalFetch(input, init); + ankidroidSave.started(); + + let response; + try { + response = await originalFetch(input, init); + } catch (error) { + ankidroidSave.finished(false); + throw error; + } + if (!response.ok) { + ankidroidSave.finished(false); + return response; + } + + // fetch() only waits for headers. Anki's postProto reads the body with + // blob(); keep navigation blocked until that read actually completes. + const readBlob = response.blob.bind(response); + response.blob = () => readSaveBody(readBlob); return response; - }); - anki.deckOptionsSaveCompleted = completed; - return completed; - }; - })(); - """.trimIndent(), - ) + }; + })(); + """.trimIndent(), + ) + } } /** @@ -372,11 +465,9 @@ suspend fun FragmentActivity.updateDeckConfigsRaw(input: ByteArray): ByteArray { undoableOp { OpChanges.parseFrom(output) } withContext(Dispatchers.Main) { if (UpdateDeckConfigsRequest.parseFrom(input).mode == UpdateDeckConfigsMode.UPDATE_DECK_CONFIGS_MODE_COMPUTE_ALL_PARAMS) { - // This HTTP request has not returned to the page yet. Wait for its response body - // before reloading, otherwise fetch can fail and display an alert that blocks navigation. - requireDeckOptionsFragment().webViewLayout.evaluateJavascript( - "anki.deckOptionsSaveCompleted.then(() => window.location.reload())", - ) + // Anki 26.05 relies on us to request a reload; 26.09 also requests one from JavaScript. + // Both requests share a flag, so the page reloads once, after reading this response. + requireDeckOptionsFragment().reloadAfterSave() } else { finish() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt index f9192b41de47..60d59b8beaf9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt @@ -191,6 +191,8 @@ abstract class CardViewerFragment( scrollBars = View.SCROLLBARS_OUTSIDE_OVERLAY setAcceptThirdPartyCookies(true) with(settings) { + // Scale the whole card with CSS instead; text-only zoom breaks MathJax layout. + textZoom = 100 javaScriptEnabled = true loadWithOverviewMode = true builtInZoomControls = true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt index 34bd7a843c03..750485314776 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt @@ -40,6 +40,9 @@ fun stdHtml( val canvasColor = ThemeUtils.getThemeAttrColor(context, android.R.attr.colorBackground).toRGBHex() val fgColor = ThemeUtils.getThemeAttrColor(context, android.R.attr.textColor).toRGBHex() val colors = ":root$rootNightMode { --canvas: $canvasColor; --fg: $fgColor; }" + // Preserve the system font size while scaling text and em-based layout together. + val fontScale = context.resources.configuration.fontScale + val fontScaleCss = if (fontScale != 1f) "html { zoom: $fontScale }" else "" val jsAssets: List = listOf( @@ -65,6 +68,7 @@ fun stdHtml( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt index 554c90f27e21..bb9e1f3f323c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt @@ -227,10 +227,49 @@ fun View.showSnackbar( snackbar.anchorView = null } + snackbar.retainUntilShown(rootView) snackbar.show() return snackbar } +/** + * Keeps the latest pending snackbar alive on [host] until shown or dismissed. + * + * Workaround to fix losing a pending snackbar after a GC (#22076). + * + * See [SnackbarManager.show and SnackbarRecord](https://github.com/material-components/material-components-android/blob/1.14.0/lib/java/com/google/android/material/snackbar/SnackbarManager.java). + */ +private fun Snackbar.retainUntilShown(host: View) { + host.retainPendingSnackbar(this) + addCallback( + object : Snackbar.Callback() { + override fun onShown(snackbar: Snackbar) = release(snackbar) + + override fun onDismissed( + snackbar: Snackbar, + event: Int, + ) = release(snackbar) + + private fun release(snackbar: Snackbar) { + host.releasePendingSnackbar(snackbar) + snackbar.removeCallback(this) + } + }, + ) +} + +/** Stores a strong reference to [snackbar] on this view, replacing any previously retained snackbar. */ +private fun View.retainPendingSnackbar(snackbar: Snackbar) { + setTag(R.id.pending_snackbar, snackbar) +} + +/** Releases the strong reference to [snackbar] only if it is still the retained snackbar. */ +private fun View.releasePendingSnackbar(snackbar: Snackbar) { + // A callback from an older snackbar must not release its replacement. + if (getTag(R.id.pending_snackbar) !== snackbar) return + setTag(R.id.pending_snackbar, null) +} + /** * Show a snackbar. * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 46ba49b5e8c6..31d528a4b9a4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -12,12 +12,13 @@ import com.ichi2.anki.tags.ManageTagsState.Error import com.ichi2.anki.tags.UserMessage.ClearedUnusedTags import com.ichi2.anki.tags.UserMessage.TagRemoved import com.ichi2.anki.tags.UserMessage.TagRenamed +import com.ichi2.anki.utils.MessageQueue +import com.ichi2.anki.utils.MessageQueue.MessageId import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -34,18 +35,26 @@ import anki.tags.TagTreeNode as BackendTagTreeNode * @see Tags for backend functions. * @see TagListItemState for display. * @see ManageTagsState for UI state. - * @see events for one-shot events. + * @see pendingMessages for messages awaiting display. */ class ManageTagsViewModel : ViewModel() { val state: StateFlow field = MutableStateFlow(ManageTagsState.Loading) - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() + /** Search field contents, available even while tags are loading or failed to load. */ + val searchQuery: StateFlow + field = MutableStateFlow("") + + private val messageQueue = MessageQueue() + + /** Messages in display order. Collecting does not consume them; acknowledge with [messageShown]. */ + val pendingMessages = messageQueue.messages /** Cached flat list of all tags, rebuilt when the backend tree changes */ private var tagList: List = emptyList() + private var tagOperation: Job? = null + init { refreshTags() } @@ -63,15 +72,17 @@ class ManageTagsViewModel : ViewModel() { * of having a filtered tag selected. */ fun filter(query: String) { - if (tagList.isEmpty()) return + searchQuery.value = query updateState { loaded -> loaded.copy( - searchQuery = query, visibleNodes = computeVisibleNodes(query), ) } } + /** Acknowledges that the UI has finished displaying a message. */ + fun messageShown(id: MessageId) = messageQueue.acknowledge(id) + /** Returns the visible node for [tag], or null with a warning if not found or not loaded */ private fun findVisibleNode(tag: TagName): TagListItemState? { val loaded = state.value as? ManageTagsState.Content @@ -95,27 +106,20 @@ class ManageTagsViewModel : ViewModel() { * @see Tags.setCollapsed */ fun toggleCollapsed(tag: TagName) = - viewModelScope.launch { - try { - val node = - findVisibleNode(tag) ?: run { - _events.send(DisplayMessage(UserMessage.UnexpectedError)) - return@launch - } - val newCollapsed = !node.collapsed - withCol { tags.setCollapsed(tag, newCollapsed) } - // Update the in-memory list with the new collapsed state - tagList = - tagList.map { - if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it - } - updateState { it.copy(visibleNodes = computeVisibleNodes(it.searchQuery)) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.w(e, "Failed to toggle collapsed state") - state.value = Error(e) - } + launchTagOperation { + val node = + findVisibleNode(tag) ?: run { + messageQueue.enqueue(UserMessage.UnexpectedError) + return@launchTagOperation + } + val newCollapsed = !node.collapsed + withCol { tags.setCollapsed(tag, newCollapsed) } + // Update the in-memory list with the new collapsed state + tagList = + tagList.map { + if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it + } + updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value)) } } /** @@ -125,7 +129,7 @@ class ManageTagsViewModel : ViewModel() { fun removeTag(tag: TagName) = launchTagOpAndRefresh { val result = undoableOp { tags.remove(tag) } - _events.send(DisplayMessage(TagRemoved(result.count))) + messageQueue.enqueue(TagRemoved(result.count)) } /** @@ -137,47 +141,78 @@ class ManageTagsViewModel : ViewModel() { newName: TagName, ) = launchTagOpAndRefresh { val result = undoableOp { tags.rename(oldName, newName) } - _events.send(DisplayMessage(TagRenamed(result.count))) + messageQueue.enqueue(TagRenamed(result.count)) } /** - * Removes tags not present on any note. Emits a [ClearedUnusedTags] event with the count. + * Removes tags not present on any note. Queues a [ClearedUnusedTags] message with the count. * @see Tags.clearUnusedTags */ fun clearUnusedTags() = launchTagOpAndRefresh { val result = undoableOp { tags.clearUnusedTags() } Timber.i("Deleted %d unused tags", result.count) - _events.send(DisplayMessage(ClearedUnusedTags(result.count))) + messageQueue.enqueue(ClearedUnusedTags(result.count)) } - private suspend fun loadTags(searchQuery: String) { + private suspend fun loadTags() { Timber.i("Loading tags from collection") tagList = flattenTree(withCol { tags.tree() }) state.value = ManageTagsState.Content( - visibleNodes = computeVisibleNodes(searchQuery), - searchQuery = searchQuery, + visibleNodes = computeVisibleNodes(searchQuery.value), ) } /** - * Sets [ManageTagsState.Loading], runs [block], then [reloads][loadTags]. - * On failure, transitions to [Error] + * Runs [block], then [reloads][loadTags], keeping any existing content visible. + */ + private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job = + launchTagOperation { + block() + loadTags() + } + + /** Starts an operation, or returns the unfinished operation's job without starting another. */ + private fun launchTagOperation(block: suspend () -> Unit): Job { + val currentOperation = tagOperation + if (currentOperation != null && !currentOperation.isCompleted) { + return currentOperation + } + + // set 'tagOperation' before executing + val operation = viewModelScope.launch(start = CoroutineStart.LAZY) { runTagOperation(block) } + tagOperation = operation + // calls from observers will now find this job + operation.start() + return operation + } + + /** + * Updates progress and errors while keeping existing content visible. + * Uses [ManageTagsState.Loading] and [Error] only when no content is available. */ - private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job { - val previousLoaded = state.value as? ManageTagsState.Content - state.value = ManageTagsState.Loading - return viewModelScope.launch { - try { - block() - loadTags(searchQuery = previousLoaded?.searchQuery ?: "") - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.w(e, "launchTagOperation failed") - state.value = Error(e) + private suspend fun runTagOperation(block: suspend () -> Unit) { + state.update { current -> + when (current) { + is ManageTagsState.Content -> current.copy(isWorking = true, error = null) + is ManageTagsState.Loading, is Error -> ManageTagsState.Loading + } + } + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Tag operation failed") + state.update { current -> + when (current) { + is ManageTagsState.Content -> current.copy(isWorking = false, error = e) + is ManageTagsState.Loading, is Error -> Error(e) + } } + } finally { + updateState { it.copy(isWorking = false) } } } @@ -195,10 +230,7 @@ class ManageTagsViewModel : ViewModel() { state.update { current -> when (current) { is ManageTagsState.Content -> transform(current) - else -> { - Timber.w("updateState called while in %s; ignoring", current::class.simpleName) - current - } + else -> current } } } @@ -319,7 +351,10 @@ sealed class ManageTagsState { data class Content( val visibleNodes: List, - val searchQuery: String = "", + /** `true` while a tag operation runs, allowing the UI to show progress without hiding the list. */ + val isWorking: Boolean = false, + /** Most recent operation failure, cleared when the next operation starts. */ + val error: Throwable? = null, ) : ManageTagsState() data class Error( @@ -327,10 +362,6 @@ sealed class ManageTagsState { ) : ManageTagsState() } -data class DisplayMessage( - val message: UserMessage, -) - sealed interface UserMessage { /** @param notesAffected number of notes the tag was removed from */ data class TagRemoved( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt new file mode 100644 index 000000000000..b93b0db1d074 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import androidx.annotation.MainThread +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** + * Keeps ViewModel messages pending until the UI has finished displaying them. + * + * e.g. Rotating the screen can remove a snackbar immediately after it's displayed. + * + * A UI should display [messages] in enqueue order, then call [acknowledge] after each message is handled. + * + * @see [Android UI event guidance](https://developer.android.com/topic/architecture/ui-layer/events#consuming-events) + */ +class MessageQueue { + val messages: StateFlow>> + field = MutableStateFlow>>(emptyList()) + + private var nextMessageId = MessageId(0) + + @MainThread + fun enqueue(message: T) { + val pending = Message(id = nextMessageId++, message = message) + messages.update { it + pending } + } + + /** Removes only the acknowledged message. Repeated or stale acknowledgements are harmless. */ + @MainThread + fun acknowledge(id: MessageId) { + messages.update { messages -> messages.filterNot { it.id == id } } + } + + /** [id] distinguishes messages with identical contents within this queue. */ + data class Message( + val id: MessageId, + val message: T, + ) + + @JvmInline + value class MessageId( + val value: Int, + ) { + operator fun inc() = MessageId(value + 1) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/OnlyOnce.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/OnlyOnce.kt index d399d17510d2..8f71752d7a4a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/OnlyOnce.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/OnlyOnce.kt @@ -2,6 +2,7 @@ package com.ichi2.anki.utils +import androidx.annotation.VisibleForTesting import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -11,13 +12,17 @@ import timber.log.Timber * Prevent multiple instances of a method being executed simultaneously */ object OnlyOnce { - private val blockedFunctions = mutableSetOf() + private val blockedFunctions = mutableSetOf() enum class Method { ANSWER_CARD, UNIT_TEST, } + @get:VisibleForTesting + internal val pendingMethods: Set + get() = blockedFunctions.toSet() + /** * Prevents multiple instances of a method being executed simultaneously * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt new file mode 100644 index 000000000000..75e586a10a84 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import android.os.Bundle +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory + +/** + * Avoids duplicating large launch arguments in saved instance state, which can cause + * [android.os.TransactionTooLargeException] when the activity stops. + * + * AndroidX normally copies all intent extras or fragment arguments into each ViewModel's saved + * state. This factory creates a new [ViewModelSavedStateHandle] (or returns one which restores + * the previously saved state). + * + * Pass launch inputs directly to the ViewModel constructor & use ViewModel state as normal. + * + * ```kt + * class EditorViewModel( + * state: ViewModelSavedStateHandle, + * val noteId: NoteId, + * ) : ViewModel() { + * val selectedField = state.getMutableStateFlow("selectedField", 0) + * } + * + * // In a Fragment: + * private val viewModel by viewModels { + * savedStateViewModelFactory { state -> + * EditorViewModel(state, requireArguments().getLong("noteId")) + * } + * } + * ``` + * + * Here, `noteId` comes from the fragment's arguments without being copied into the handle. + * Edits to `selectedField.value` are saved and restored after process death. + */ +inline fun savedStateViewModelFactory( + crossinline create: (ViewModelSavedStateHandle) -> VM, +): ViewModelProvider.Factory = + viewModelFactory { + initializer { + val extras = MutableCreationExtras(this) + extras[DEFAULT_ARGS_KEY] = Bundle.EMPTY + create(extras.createSavedStateHandle()) + } + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt new file mode 100644 index 000000000000..a698c21d87fc --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import androidx.lifecycle.SavedStateHandle + +/** + * A [SavedStateHandle] created without automatically copying intent extras or fragment arguments. + * + * @see savedStateViewModelFactory + */ +typealias ViewModelSavedStateHandle = SavedStateHandle diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt index ddf554c25035..97eaa8a521ea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt @@ -34,9 +34,8 @@ import com.ichi2.anki.notifications.NotificationId import com.ichi2.anki.setLastSyncTimeToNow import com.ichi2.anki.settings.Prefs import com.ichi2.anki.utils.ext.trySetForeground -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @@ -116,29 +115,31 @@ class SyncWorker( syncMedia: Boolean, ) { Timber.v("SyncWorker::syncCollection") - val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val monitor = - scope.launch { - val backend = CollectionManager.getBackend() - var syncProgress: Progress.NormalSync? = null - while (true) { - val progress = backend.latestProgress() // avoid sending repeated notifications - if (progress.hasNormalSync() && syncProgress != progress.normalSync) { - syncProgress = progress.normalSync - val text = syncProgress.run { "$added\n$removed" } - notify(getProgressNotification(text)) - } - delay(SyncMediaWorker.NOTIFICATION_UPDATE_RATE_MS) - } - } val response = - try { - withCol { - syncCollection(auth, syncMedia = false) + coroutineScope { + val monitor = + launch(Dispatchers.IO) { + val backend = CollectionManager.getBackend() + var syncProgress: Progress.NormalSync? = null + while (true) { + val progress = backend.latestProgress() // avoid sending repeated notifications + if (progress.hasNormalSync() && syncProgress != progress.normalSync) { + syncProgress = progress.normalSync + val text = syncProgress.run { "$added\n$removed" } + notify(getProgressNotification(text)) + } + delay(SyncMediaWorker.NOTIFICATION_UPDATE_RATE_MS) + } + } + try { + withCol { + syncCollection(auth, syncMedia = false) + } + } finally { + Timber.d("Collection sync completed. Cancelling monitor...") + // this doesn't wait for completion; the outer `coroutineScope` does. + monitor.cancel() } - } finally { - Timber.d("Collection sync completed. Cancelling monitor...") - monitor.cancel() } Timber.i("Sync required: %s", response.required) when (response.required) { diff --git a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_add_custom_item.xml b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_add_custom_item.xml index fad10f62a112..e464233ec972 100644 --- a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_add_custom_item.xml +++ b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_add_custom_item.xml @@ -4,7 +4,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" - android:padding="16dp"> + android:padding="24dp"> + android:paddingBottom="16dp"/> + android:layout_height="wrap_content"/> + android:layout_height="wrap_content"/> + android:layout_height="wrap_content"/> diff --git a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml index 703128b63690..a874b3bef807 100644 --- a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml +++ b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml @@ -8,7 +8,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:padding="16dp"> + android:padding="24dp"> + android:paddingBottom="16dp"/> + android:layout_height="wrap_content"/> @@ -80,25 +80,25 @@ android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginTop="8dp" android:hint="@string/before_text"> + android:layout_height="wrap_content"/> + android:layout_height="wrap_content"/> diff --git a/AnkiDroid/src/main/res/layout/dialog_tags.xml b/AnkiDroid/src/main/res/layout/dialog_tags.xml index 7625d3248b43..4763b4e9292e 100644 --- a/AnkiDroid/src/main/res/layout/dialog_tags.xml +++ b/AnkiDroid/src/main/res/layout/dialog_tags.xml @@ -7,14 +7,6 @@ - - - + android:layout_below="@id/toolbar"> + + + + + कठिन अच्छा आसान - \'%s\' is enabled + \'%s\' सक्रिय हो गया है। पूर्ववत् करें रीडू diff --git a/AnkiDroid/src/main/res/values-ja/02-strings.xml b/AnkiDroid/src/main/res/values-ja/02-strings.xml index 303ce999cf75..8d809ce29318 100644 --- a/AnkiDroid/src/main/res/values-ja/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ja/02-strings.xml @@ -255,7 +255,7 @@ 有効なメールアドレスを入力してください パスワードが必要です - もう一度「戻る」操作で終了 + もう一度「戻る」操作で終了します %s%% デッキをダウンロード diff --git a/AnkiDroid/src/main/res/values-ja/10-preferences.xml b/AnkiDroid/src/main/res/values-ja/10-preferences.xml index a97ab9518218..172d7986baff 100644 --- a/AnkiDroid/src/main/res/values-ja/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ja/10-preferences.xml @@ -229,13 +229,13 @@ Schedule reminders リマインダーを追加 学習のリマインダーがありません - Troubleshoot - Notification settings + トラブルシューティング + アプリの通知設定 List of scheduled review reminders Deck not found - Reminders are unavailable - Fix - Successfully added new review reminder + リマインダーが機能していません + 設定変更 + 新しい学習リマインダーが正常に追加されました Successfully deleted review reminder Successfully edited review reminder @@ -252,37 +252,37 @@ Delete this reminder? This action cannot be undone. - Reminders are unavailable. - Reminders may not work correctly. - Your reminders should work as expected. + リマインダーが機能していません。 + リマインダーが正常に動作しない可能性があります。 + リマインダーは正常に動作するはずです。 Different devices have different notification restriction settings. Check your system settings or search online if issues persist. - Notification permission - Notification channel - Do not disturb - Battery optimization - Power saving mode + 通知の許可 + 通知チャンネル + サイレントモード + バッテリーの最適化 + 省電力モード Alarms & reminders permission - Granted - Denied - On - Off - Unrestricted - Optimized - Restricted + 許可済み + 許可されていません + オン + オフ + このアプリには適用されていません + このアプリにも適用しています + このアプリの使用を制限しています - The review reminder notification channel must be enabled + 「学習リマインダー」というカテゴリーの通知を許可する必要があります Do Not Disturb may mute reminder notifications - Battery optimization may delay reminders - Background usage is disabled. Reminders may not be delivered - Power saving mode may prevent timely delivery of reminders + バッテリーの最適化により、リマインダーが遅延する可能性があります + バックグラウンドでの使用が制限されています。リマインダーが配信されない可能性があります。 + 省電力モードによってリマインダーの通知が遅れる可能性があります Required to schedule reminders at exact times - Grant permission - Enable notification channel - Disable battery optimization - Open battery settings + 許可を与える + 上記カテゴリの通知を許可 + このアプリには適用しない + バッテリー設定を開く 学習する時間です %s を学習する時間です diff --git a/AnkiDroid/src/main/res/values-ko/02-strings.xml b/AnkiDroid/src/main/res/values-ko/02-strings.xml index 3afa228db020..b338bd892886 100644 --- a/AnkiDroid/src/main/res/values-ko/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ko/02-strings.xml @@ -112,7 +112,7 @@ 목표 학습 시간에 도달했습니다. - %2$s분 동안 카드 %1$d개를 복습했습니다 + %2$s 동안 카드 %1$d개를 복습했습니다 diff --git a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml index ebffb7dde89f..9d9dd2c2a1d5 100644 --- a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml @@ -134,7 +134,7 @@ Chyba pri odstránení obrázka Nepodarilo sa použiť obrázok pozadia %s Pozadie Deck Picker je príliš veľké - Maximum image size %d MB allowed + Najväčšia povolená veľkosť obrázka je %d MB Rozmery obrázka sú priveľké (%1$d x %2$d) diff --git a/AnkiDroid/src/main/res/values-sk/10-preferences.xml b/AnkiDroid/src/main/res/values-sk/10-preferences.xml index 7ec9a48b9025..dd484891b10a 100644 --- a/AnkiDroid/src/main/res/values-sk/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sk/10-preferences.xml @@ -52,12 +52,12 @@ Upravovanie Zabezpečenie - Globally allow dangerous JavaScript APIs - Allows any card or template to read and modify other cards, or your Anki collection data.\nLeave off unless you trust all cards and templates in your collection. + Globálne povoliť nebezpečné JS API + Umožňuje akejkoľvek kartičke alebo šablóne čítať a meniť kartičky alebo údaje vašej Anki kolekcie.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii. Security: Unsafe JavaScript call blocked.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settings - Allow cards to launch other apps - Cards can open other apps on your device, triggering potentially unwanted actions.\nLeave off unless you trust all cards and templates in your collection. + Povoliť kartičkám spúšťanie iných aplikácií + Kartičky môžu otvoriť iné aplikácia vo vašom zariadení, potencionálne spúšťajúc nechcenú aktivitu.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii. Security: Card blocked from launching another app.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settings Hrúbka ťahu @@ -147,9 +147,9 @@ Zvoliť jazyk Vypnúť hardvérové renderovanie kartičky Hardvérové renderovanie je rýchlejšie, ale môže spôsobiť problémy, najmä na Android 8/8.1. Ak nemôžete vidieť časti užívateľského rozhrania pri opakovaní kartičiek, skúste toto nastavenie. - Disable single-field edit mode + Zakázať režim úpravy jedného poľa - Allows \'Cloze deletion\' context menu when in landscape mode. + Povoliť kontextové menu doplňovačky pri horizontálnom zobrazení. Zarovnanie na stred Vycentrovať obsah kartičiek vertikálne Čas oneskorenia dvojitého dotyku @@ -160,8 +160,8 @@ Zobraziť čas ďalšieho opakovania nad tlačidlami odpovedí Ukázať veľké tlačidla pre odpovede Ukázať tlačidlá odpovede vo dvoch riadkoch - Remove animations - Also enabled if animations are removed on the system settings + Odstrániť animácie + Je povolené aj ak sú animácie odstránené systémovými nastaveniami Zobraziť hornú lištu Show card counts, answer timer, flag and mark in top bar Zobraziť zostávajúce @@ -310,7 +310,7 @@ Zdieľať používanie prvkov Môžete prispieť pre AnkiDroid tak, že umožníte vývojovému tímu sledovať, ktoré prvky sa používajú Nahradiť nové riadky s HTML - In the note editor, convert any instances of <br> to newlines when editing a card. + Pri upravovaní poznámky zmení všetky prvky <br> na nové riadky počas upravovania kartičky. Vybrať „napíšte odpoveď“ Automaticky vyberie pole „napíšte odpoveď“ na obrazovke študovania. @@ -358,8 +358,8 @@ Experimental AnkiDroid has introduced a better TTS mechanism which is compatible with other Anki clients and includes more voices and improvements to language playback!\n\nPlease upgrade as soon as possible, as this setting will be removed soon Please upgrade to the new text to speech format - Allow templates to record audio - This template is trying to record audio + Povoliť šablónam nahrávať zvuk + Táto šablóna sa pokúša nahrávať zvuk localStorage na obrazovke študovania Povoliť JavaScript localStorage (lokálne úložisko) použitím fixného portu servera na obrazovke študovania diff --git a/AnkiDroid/src/main/res/values-sk/11-arrays.xml b/AnkiDroid/src/main/res/values-sk/11-arrays.xml index 852cc8588fa9..0a40c7747ffa 100644 --- a/AnkiDroid/src/main/res/values-sk/11-arrays.xml +++ b/AnkiDroid/src/main/res/values-sk/11-arrays.xml @@ -35,7 +35,7 @@ Svetlý Jednoduchý - E-Ink + E-Tuš Čierny Tmavý diff --git a/AnkiDroid/src/main/res/values-ug/01-core.xml b/AnkiDroid/src/main/res/values-ug/01-core.xml index 21d261445901..3adf3cc3eda8 100644 --- a/AnkiDroid/src/main/res/values-ug/01-core.xml +++ b/AnkiDroid/src/main/res/values-ug/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ساندانىنى ئوقۇيدۇ ۋە يازىدۇ مەۋجۇت خاتىرە، كارتا، خاتىرە تۈرى ۋە دەستىنى زىيارەت قىلىپلا قالماي يەنە يېڭىسىنى قۇرىدۇ - Card browser - Anki card + كارتا كۆرگۈچ + Anki كارتا توپلامنى بىرلەشتۈرگىلى بولمايدۇ.\nقايسى توپلامنى ساقلاپ قالىسىز؟ AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ug/02-strings.xml b/AnkiDroid/src/main/res/values-ug/02-strings.xml index 3c3dfc822322..a7b1617eabc4 100644 --- a/AnkiDroid/src/main/res/values-ug/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ug/02-strings.xml @@ -220,8 +220,8 @@ خەت چوڭلۇقى قورال بالداقنى كۆرسەت - Show preview - Hide preview + ئالدىن كۆزىتىشنى كۆرسەت + ئالدىن كۆزىتىشنى يوشۇر توم پىچىم يانتۇ پىچىم ئاستى سىزىق پىچىم diff --git a/AnkiDroid/src/main/res/values-ug/03-dialogs.xml b/AnkiDroid/src/main/res/values-ug/03-dialogs.xml index 601708e3a98d..b9238fe480f8 100644 --- a/AnkiDroid/src/main/res/values-ug/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ug/03-dialogs.xml @@ -49,7 +49,7 @@ بۈگۈنكى تەكرارلايدىغان كارتا چېكىنى («3-»)ئاشۇرىدۇ/كېمەيتىدۇ ئۆتكەن x كۈندە ئۇنتۇلغان كارتىنى تەكرارلايدۇ: كېيىنكى كارتىنى تەكرارلاش مۆھلىتى: - Preview new cards added in the last: + يېقىنقى كۈندە قوشقان يېڭى كارتىنى ئالدىن كۆزىتىدۇ: دەستىدىن x كارتا تاللىنىدۇ: ساقلىغۇچ لىق ساقلىغۇچ ئاساسەن لىق @@ -78,7 +78,7 @@ قاپلا تۈزەت ئالماشتۇر - %1$s\n\nCorrect the date and time, or enable automatic date and time, then sync again.\n\nYour device’s current time is: %2$s + %1$s\n\nچېسلا ۋە ۋاقىتنى توغرىلاپ ياكى ئاپتوماتىك چېسلا ۋە ۋاقىتنى قوزغىتىپ ئاندىن قايتا قەدەمداشلاڭ.\n\nئۈسكۈنىڭىزنىڭ نۆۋەتتىكى ۋاقتى: %2$s بەلگىلەنگەن يول ئىناۋەتلىك مۇندەرىجە ئەمەس تەمىنلەنگەن تېكىستنى ئىناۋەتلىك PEM كودىنىڭ X509 گۇۋاھنامىسىنى بىر تەرەپ قىلالمىدى گۇۋاھنامە يېشىش خاتالىقى @@ -87,7 +87,7 @@ چوڭ ۋاسىتە توپلىمىغا ئۇزۇن ۋاقىت كېتىشى مۇمكىن. داۋاملاشتۇرامدۇ؟ ۋاسىتە تەكشۈرۈۋاتىدۇ… بەلگە قوش… - Tags added + بەلگە قوشۇلدى ۋاسىتە ئۆچۈرۈۋاتىدۇ… ئۆچۈرۈش نەتىجىسى diff --git a/AnkiDroid/src/main/res/values-ug/10-preferences.xml b/AnkiDroid/src/main/res/values-ug/10-preferences.xml index 8c590afe6a10..3389003637eb 100644 --- a/AnkiDroid/src/main/res/values-ug/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ug/10-preferences.xml @@ -24,7 +24,7 @@ --> - Enabled + قوزغىتىلدى چەكلەنگەن %d مىنۇت @@ -108,7 +108,7 @@ ئۈچ بارماقتا چېكىش تۆت بارماقتا چېكىش ئۈسكۈنىنى سىلكى - ‘%s’ menu + «%s» تىزىملىكى «%s» مەزمۇن تىزىملىكىنى پۈتكۈل دائىرىدە قوزغىتىدۇ قوش دومىلات ئېلېكتىرونلۇق ئوقۇغۇچتا قوش دومىلىتىدۇ @@ -224,75 +224,75 @@ ################################## Review reminders ################################### ####################################################################################### --> - Review reminders - App-wide - Deck-specific - An error occurred while loading your review reminders, corrupted reminders have been deleted. Details: + تەكرارلاش ئەسكەرتىشى + ئەپ مىقياسىدا + دەستە بەلگىلەش + تەكرارلاش ئەسكەرتىشىڭىزنى يۈكلەۋاتقاندا خاتالىق كۆرۈلدى، بۇزۇلغان ئەسكەرتىش ئۆچۈرۈلدى. تەپسىلاتى: - Schedule reminders + ئەسكەرتىش كۈنتەرتىپى ئەسكەرتىش قوش تەكرارلاش ئەسكەرتىشىڭىز يوق - Troubleshoot - Notification settings - List of scheduled review reminders - Deck not found - Reminders are unavailable - Fix - Successfully added new review reminder - Successfully deleted review reminder - Successfully edited review reminder + كاشىلا ئوڭشاش + ئۇقتۇرۇش تەڭشىكى + كۈنتەرتىپتىكى تەكرارلاش ئەسكەرتىش تىزىمى + دەستە تېپىلمىدى + ئەسكەرتكۈچنى ئىشلەتكىلى بولمايدۇ + ئوڭشا + يېڭى تەكرارلاش ئەسكەرتىشى مۇۋەپپەقىيەتلىك قوشۇلدى + تەكرارلاش ئەسكەرتىشى مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى + تەكرارلاش ئەسكەرتىشى مۇۋەپپەقىيەتلىك تەھرىرلەندى تەكرارلاش ئەسكەرتىشى قوش تەكرارلاش ئەسكەرتىشى تەھرىر - Time: - Deck: - Advanced settings - Card threshold: - Do not send me notifications if there are less than this many cards due - Only notify me if no reviews have been done today - Please enter a whole number of cards - The threshold must be at least 0 - Delete this reminder? - This action cannot be undone. + ۋاقىت: + دەستە: + ئالىي تەڭشەك + كارتا بوسۇغا قىممىتى: + ئەگەر بۇنىڭدىن ئاز كارتا بولسا ئۇقتۇرۇشنى ماڭا يوللىما + ئەگەر بۈگۈن تەكرارلاشنىنى تاماملىمىغان بولسام ئاندىن مېنى ئەسكەرت + تولۇق كارتا نومۇرىنى كىرگۈزۈڭ + بوسۇغا قىممىتى كەم دېگەندە 0 بولۇشى كېرەك + بۇ ئەسكەرتىشنى ئۆچۈرەمدۇ؟ + بۇ مەشغۇلاتتىن يېنىۋالغىلى بولمايدۇ. - Reminders are unavailable. - Reminders may not work correctly. - Your reminders should work as expected. - Different devices have different notification restriction settings. Check your system settings or search online if issues persist. + ئەسكەرتكۈچنى ئىشلەتكىلى بولمايدۇ. + ئەسكەرتكۈچ توغرا ئىشلىمەسلىكى مۇمكىن. + ئەسكەرتكۈچىڭىز كۈتكەندەك ئىشلىشى كېرەك. + پەرقلىق ئۈسكۈنىنىڭ پەرقلىق ئۇقتۇرۇش چەك تەڭشىكى بولىدۇ. ئەگەر مەسىلە يەنىلا مەۋجۇت بولسا سىستېما تەڭشىكىنى تەكشۈرۈڭ ياكى توردىن ئىزدەڭ. - Notification permission - Notification channel - Do not disturb - Battery optimization - Power saving mode - Alarms & reminders permission + ئۇقتۇرۇش ئىجازىتى + ئۇقتۇرۇش قانىلى + دەخلى قىلماڭ + توكدان ئەلالاشتۇرۇش + توك تېجەش ھالىتى + قوڭغۇراق ۋە ئەسكەرتكۈچ ئىجازىتى - Granted - Denied - On - Off - Unrestricted - Optimized - Restricted + بېرىلدى + رەت قىلىندى + ئوچۇق + تاقاق + چەكلىمىسىز + ئەلالاشتۇرۇلغان + چەكلىمىلىك - The review reminder notification channel must be enabled - Do Not Disturb may mute reminder notifications - Battery optimization may delay reminders - Background usage is disabled. Reminders may not be delivered - Power saving mode may prevent timely delivery of reminders - Required to schedule reminders at exact times + تەكرارلاشنى ئەسكەرتكۈچ ئۇقتۇرۇش قانىلى چوقۇم قوزغىتىلىشى كېرەك + دەخلى قىلماڭ ھالىتى ئەسكەرتكۈچ ئۇقتۇرۇشىنى ئۈنسىزلىشى مۇمكىن + توكدان ئەلالاشتۇرۇش ئەسكەرتكۈچنى كېچىكتۈرۈشى مۇمكىن + ئارقا سۇپىدا ئىشلىتىش چەكلەنگەن. ئەسكەرتكۈچ يەتكۈزۈلمەسلىكى مۇمكىن. + توك تېجەش ھالىتى ئەسكەرتكۈچنىڭ ۋاقتىدا يەتكۈزۈشىنىڭ ئالدىنى ئېلىشى مۇمكىن + ئەسكەرتكۈچنى مۇئەييەن ۋاقىتقا كۈنتەرتىپكە تىزىش ئۈچۈن زۆرۈر - Grant permission - Enable notification channel - Disable battery optimization - Open battery settings + ئىجازەت بەر + ئۇقتۇرۇش قانىلىنى قوزغات + توكدان ئەلالاشتۇرۇشنى چەكلە + توكدان تەڭشىكىنى ئاچ - It\'s time to study your cards - It\'s time to study \"%s\" - Snooze %s + كارتىڭىزنى ئۆگىنىدىغان ۋاقىت بولدى + «%s» نى ئۆگىنىدىغان ۋاقىت بولدى + %s كېچىكتۈر - %d card due - %d cards due + %d كارتا مۆھلىتى + %d كارتا مۆھلىتى بەلگە تاللىنىدۇ @@ -443,15 +443,15 @@ قايتۇرما ئىنكاس ياكى كەمتۈك دوكلاتى يوللاڭ.]]> جاۋاب قايتۇرما ئىنكاسىنى كۆرسىتىدۇ ئېكران - Also assign to ‘%1$s’? - Skip - Assign + «%1$s» غىمۇ تەقسىملەمدۇ؟ + ئاتلا + تەقسىملە تەڭشەكنى ئاچ مۇناسىۋەتلىك تەڭشەك - Not set - Custom path - Enter custom path - Directory is not writable + تەڭشەلمىگەن + ئىختىيارى يول + ئىختىيارى يول كىرگۈزۈلىدۇ + مۇندەرىجىگە يازغىلى بولمايدۇ diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 271234bce2d1..f6ae538d9976 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -259,6 +259,8 @@ Edit toolbar item Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it Remove toolbar item? + Button text is required + At least one HTML field is required The image is too large, please insert the image manually The video file is too large, please insert the video manually diff --git a/AnkiDroid/src/main/res/values/ids.xml b/AnkiDroid/src/main/res/values/ids.xml index f70272303cae..e2d4feb53b21 100644 --- a/AnkiDroid/src/main/res/values/ids.xml +++ b/AnkiDroid/src/main/res/values/ids.xml @@ -43,4 +43,6 @@ + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/styles.xml b/AnkiDroid/src/main/res/values/styles.xml index 2e64bb90f128..f5ba1bb96faa 100644 --- a/AnkiDroid/src/main/res/values/styles.xml +++ b/AnkiDroid/src/main/res/values/styles.xml @@ -291,6 +291,11 @@ ?attr/editTextHighlightColor + + +