diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index b6da152e6d51..82d73dee38c1 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -1,6 +1,9 @@ name: 🛠️ Emulator Tests on: + schedule: + # Weekly emulator coverage, Monday at 03:17 UTC. + - cron: '17 3 * * 1' workflow_dispatch: inputs: iterations: @@ -21,7 +24,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'schedule' && 'scheduled' || 'ci' }} cancel-in-progress: true jobs: @@ -56,15 +59,6 @@ jobs: console.log('iterationArray is: ' + iterationArray) } - // If we are running on a schedule it's our periodic passive scan for flakes - // Goal is to run enough iterations on all systems that we have confidence there are no flakes - if (context.eventName === "schedule") { - const iterationCount = 15 - for (let i = 0; i < iterationCount; i++) { - iterationArray.push(i); - } - } - // TODO Refactor to make these dynamic with a minSdk/max bracket only on schedule, not push let apiLevel = [36]; @@ -72,6 +66,11 @@ jobs: // try different architectures, targets, delays etc let arch = ['x86_64']; let target = ['google_apis', 'google_apis_ps16k']; + // Keep the 16 KB emulator off the per-change CI path. + // Scheduled and manual runs exercise both targets. + if (context.eventName !== 'schedule' && context.eventName !== 'workflow_dispatch') { + target = ['google_apis']; + } let firstBootDelay = [600]; return { @@ -138,17 +137,16 @@ jobs: # Comment this and the with: above out for performance testing on a branch cache-read-only: ${{ github.ref != 'refs/heads/main' }} - # This appears to be 'Cache Size: ~1230 MB (1290026823 B)' based on watching action logs - # Repo limit is 10GB; branch caches are independent; branches may read default branch cache. - # We don't want branches to evict main branch snapshot, so save on main, read-only all else + # Cache only the per-change emulator: the 16 KB snapshot costs about 2.5 GiB. - name: AVD cache + if: matrix.target == 'google_apis' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: path: | ~/.android/avd/* ~/.android/adb* - key: avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2-${{ github.event.inputs.clearCaches }} + key: avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2- restore-keys: | avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2 @@ -191,9 +189,9 @@ jobs: "${ANDROID_HOME:?}/system-images/android-${SDK_API_LEVEL:?}/${SDK_TARGET:?}/${SDK_ARCH:?}/.installer" - name: AVD Boot and Snapshot Creation - # Only generate a snapshot for saving if we are on main branch with a cache miss + # Warm up the uncached 16 KB emulator on every run; otherwise only on main cache misses. # Comment the if out to generate snapshots on branch for performance testing - if: "${{ github.event.inputs.clearCaches != '' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: ${{ matrix.api-level }} @@ -212,10 +210,10 @@ jobs: # This step is separate so pure install time may be calculated as a step - name: Emulator Snapshot After Firstboot Warmup - # Only generate a snapshot for saving if we are on main branch with a cache miss + # Always warm up the 16 KB emulator. The default emulator only needs it on main cache misses. # Switch the if statements via comment if generating snapshots for performance testing # if: matrix.first-boot-delay != '0' - if: "${{ github.event.inputs.clearCaches != '' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" env: FIRST_BOOT_DELAY: ${{ matrix.first-boot-delay }} uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle deleted file mode 100644 index 1614e965532d..000000000000 --- a/AnkiDroid/build.gradle +++ /dev/null @@ -1,877 +0,0 @@ -import com.ichi2.anki.gradle.VersionCodeTasks -import com.ichi2.anki.gradle.WriteReleaseMetadata - -plugins { - // Gradle plugin portal - alias(libs.plugins.tripletPlay) - id 'ankidroid.android.app' - id 'ankidroid.plugins.jacoco' - alias(libs.plugins.androidx.baselineprofile) - alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.keeper) - alias(libs.plugins.roborazzi) - id 'idea' -} - -repositories { - google() - mavenCentral() - maven { url = "https://jitpack.io" } -} - -keeper { - traceReferences { - // Silence missing definitions - arguments.set(["--map-diagnostics:MissingDefinitionsDiagnostic", "error", "none"]) - } -} - -baselineProfile { - // Write generated profiles to `src/main/baselineProfiles/` instead of the - // default per-variant `src/playRelease/generated/baselineProfiles/`. This - // ensures the profile is consumed by ALL variants (play, amazon, full) and - // ALL buildTypes (release, benchmark), not just `playRelease`. - mergeIntoMain = true -} - -idea { - module { - downloadJavadoc = System.getenv("CI") != "true" - downloadSources = System.getenv("CI") != "true" - } -} - -def homePath = System.properties['user.home'] -/** - * Calculates the current git hash, invalidates configuration cache if changed - * @example edf739d95bad7b370a6ed4398d46723f8219b3cd - */ -// https://docs.gradle.org/current/userguide/configuration_cache_requirements.html#config_cache:requirements:external_processes -def gitCommitHash = providers.exec { - commandLine "git", "rev-parse", "HEAD" -}.standardOutput.asText.map { it.trim() } - -/** - * Epoch millis of the build. Displayed on the 'About' screen. - * - * CI passes the real time via -PbuildTime= # example: 1776422957 - * - * A local build uses a stale value (captured in the configuration cache) so the configuration cache - * is not invalidated on every build. - */ -def buildTimeMillis = providers.gradleProperty("buildTime") - .orElse(providers.provider { System.currentTimeMillis().toString() }) - -android { - namespace = "com.ichi2.anki" - - buildFeatures { - buildConfig = true - aidl = true - viewBinding = true - resValues = true - compose = true - } - - if (rootProject.testReleaseBuild) { - testBuildType = "release" - } else { - testBuildType = "debug" - } - - defaultConfig { - applicationId = "com.ichi2.anki" - buildConfigField "Boolean", "CI", (System.getenv("CI") == "true").toString() - buildConfigField "String", "ACRA_URL", '"https://ankidroid.org/acra/report"' - buildConfigField "String", "BACKEND_VERSION", "\"${libs.versions.ankiBackend.get()}\"" - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", "false" - buildConfigField "String", "GIT_COMMIT_HASH", "\"${gitCommitHash.get()}\"" - buildConfigField "long", "BUILD_TIME", buildTimeMillis.get() - // whether UI elements linking to external donation pages are shown ('play' hides them) - buildConfigField "Boolean", "SHOW_DONATE_LINKS", "true" - buildConfigField "int", "ANKIQUEST_RELEASE", (project.findProperty("ankiquestRelease") ?: "0").toString() - buildConfigField "int", "ANKIQUEST_NIGHTLY", (project.findProperty("ankiquestNightly") ?: "0").toString() - resValue "string", "app_name", "AnkiDroid" - - // The version number is of the form: - // ..[dev|alpha|beta|] - // The is only present for alpha and beta releases (e.g., 2.0.4alpha2 or 2.0.4beta4), developer builds do - // not have a build number (e.g., 2.0.4dev) and official releases only have three components (e.g., 2.0.4). - // - // The version code is derived from the version name as follows: - // AbbCCtDD - // A: 1-digit decimal number representing the major version - // bb: 2-digit decimal number representing the minor version - // CC: 2-digit decimal number representing the maintenance version - // t: 1-digit decimal number representing the type of the build - // 0: developer build - // 1: alpha release - // 2: beta release - // 3: public release - // DD: 2-digit decimal number representing the build - // 00 for internal builds and public releases - // alpha/beta build number for alpha/beta releases - // - // 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=22600102 - // 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.0alpha2" - 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, - // so both channels install over each other in order. - // A setter, so upstream's validateVersionCode still sees one versionCode literal. - if ((project.findProperty("customSuffix") ?: "").toString().replaceFirst(/^\.*/, "") == "quest") { - def questRelease = (project.findProperty("ankiquestRelease") ?: "0").toString() - def questNightly = (project.findProperty("ankiquestNightly") ?: "0").toString() - if (!(questRelease ==~ /[0-9]{1,5}/) || questRelease.toInteger() > 49999) { - throw new GradleException("ankiquestRelease must be a release number from 0 to 49999") - } - if (!(questNightly ==~ /[0-9]{1,3}/)) { - throw new GradleException("ankiquestNightly must be a commit count from 0 to 999") - } - if (questRelease.toInteger() > 0) { - setVersionCode(50000000 + questRelease.toInteger() * 1000 + questNightly.toInteger()) - } - } - - // GA4 Measurement Protocol api_secret. Sourced from `local.properties` - // (ANALYTICS_API_KEY) or the ANALYTICS_API_KEY env var; falls back to - // a dummy value so contributor builds still compile. - // - // The dummy key is rejected by GA's ingest, so analytics requests from - // unsigned/local builds go nowhere this means `setDevMode()` (100% - // sampling) has no observable effect on a build without the real key. - // - // Google documents the api_secret as private, and the Measurement Protocol - // as intended for trusted/server-side use: - // https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events#api_secret - // Shipping it in the APK means anyone can extract it and post junk events to - // the property, which would corrupt our reporting. We accept that: there is no - // server-side path for an offline-first app, the property holds no user data, - // and a polluted property can be rotated by issuing a new secret. - Properties properties = new Properties() - def localPropsFile = project.rootProject.file("local.properties") - if (localPropsFile.exists()) { - properties.load(localPropsFile.newDataInputStream()) - } - - def apiKey = properties.getProperty("ANALYTICS_API_KEY") ?: System.getenv("ANALYTICS_API_KEY") ?: "DUMMY_API_XXX" - // Keep credentials out of BuildConfig: ACRA collects its fields through reflection. - resValue "string", "ga_api_secret", apiKey - } - signingConfigs { - release { - def keystorePath = System.getenv("KEYSTOREPATH") - if (keystorePath != null && !keystorePath.trim().isEmpty()) { - storeFile file(keystorePath) - storePassword System.getenv("KEYSTOREPWD") ?: System.getenv("KSTOREPWD") - keyAlias System.getenv("KEYALIAS") - keyPassword System.getenv("KEYPWD") - } else { - storeFile file("${rootDir}/tools/fallback-release-keystore.jks") - storePassword "Test@123" - keyAlias "my-key" - keyPassword "Test@123" - } - } - } - buildTypes { - named('debug') { - versionNameSuffix "-debug" - debuggable true - applicationIdSuffix ".debug" - splits.abi.universalApk = true // Build universal APK for debug always - // Check Crash Reports page on developer wiki for info on ACRA testing - // buildConfigField "String", "ACRA_URL", '"https://918f7f55-f238-436c-b34f-c8b5f1331fe5-bluemix.cloudant.com/acra-ankidroid/_design/acra-storage/_update/report"' - if (project.rootProject.file('local.properties').exists()) { - Properties localProperties = new Properties() - localProperties.load(project.rootProject.file('local.properties').newDataInputStream()) - // #6009 Allow optional disabling of JaCoCo for general build (assembleDebug). - // jacocoDebug task was slow, hung, and wasn't required unless I wanted coverage - testCoverageEnabled = localProperties['enable_coverage'] != "false" - // not profiled: optimization for build times - if (localProperties['enable_languages'] == "false") { - android.defaultConfig.resConfigs "en" - } - // allow disabling leak canary - if (localProperties["enable_leak_canary"] != null) { - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", localProperties["enable_leak_canary"] - } else { - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", "true" - } - } else { - testCoverageEnabled true - } - - // make the icon red if in debug mode - resValue 'color', 'anki_foreground_icon_color_0', "#FFFF0000" - resValue 'color', 'anki_foreground_icon_color_1', "#FFFF0000" - } - named('release') { - testCoverageEnabled = testReleaseBuild - minifyEnabled System.getenv("MINIFY_ENABLED") ? System.getenv("MINIFY_ENABLED") != "false" : true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - testProguardFile 'proguard-test-rules.pro' - splits.abi.universalApk = universalApkEnabled // Build universal APK for release with `-Duniversal-apk=true` - signingConfig = signingConfigs.release - - // syntax: assembleRelease -PcustomSuffix="suffix" -PcustomName="New name" - if (project.hasProperty("customSuffix")) { - // the suffix needs a '.' at the start - applicationIdSuffix project.property("customSuffix").replaceFirst(/^\.*/, ".") - } - if (project.hasProperty("customName")) { - resValue "string", "app_name", project.property("customName") - } - - resValue 'color', 'anki_foreground_icon_color_0', "#FF29B6F6" - resValue 'color', 'anki_foreground_icon_color_1', "#FF0288D1" - } - - create('benchmark') { - initWith release - signingConfig signingConfigs.debug - matchingFallbacks = ['release'] - debuggable false - } - } - - /** - * Product Flavors are used for Amazon App Store and Google Play Store. - * This is because we cannot use Camera Permissions in Amazon App Store (for FireTv etc...) - * Therefore, different AndroidManifest for Camera Permissions is used in Amazon flavor. - */ - flavorDimensions += "appStore" - productFlavors { - create('play') { - getIsDefault().set(true) - dimension "appStore" - // Google Play no longer considers Open Collective to be acceptable for - // a 'tax-exempt' link. Temporarily remove our links - buildConfigField "Boolean", "SHOW_DONATE_LINKS", "false" - } - create('amazon') { - dimension "appStore" - } - // A 'full' build has no restrictions on storage/camera. Distributed on GitHub/F-Droid - create('full') { - dimension "appStore" - } - } - - /** - * Set this to true to create five separate APKs instead of one: - * - 2 APKs that only work on ARM/ARM64 devices - * - 2 APKs that only works on x86/x86_64 devices - * - a universal APK that works on all devices - * The advantage is the size of most APKs is reduced by about 2.5MB. - * Upload all the APKs to the Play Store and people will download - * the correct one based on the CPU architecture of their device. - */ - def enableSeparateBuildPerCPUArchitecture = true - - /** - * Restricts the build to a single CPU architecture, e.g. `-PabiFilter=arm64-v8a`. - * Allows F-Droid to build one APK per architecture without patching this file (#21399). - */ - def abiFilter = providers.gradleProperty("abiFilter").getOrNull() - def supportedAbis = ["armeabi-v7a", "x86", "arm64-v8a", "x86_64"] - if (abiFilter != null && !supportedAbis.contains(abiFilter)) { - throw new GradleException("Unsupported -PabiFilter value '$abiFilter'. Expected one of: $supportedAbis") - } - - splits { - abi { - reset() - enable = enableSeparateBuildPerCPUArchitecture - //universalApk enableUniversalApk // set in debug + release config blocks above - if (abiFilter != null) { - include abiFilter - } else { - include "armeabi-v7a", "x86", "arm64-v8a", "x86_64" - } - } - } - // applicationVariants are e.g. debug, release - applicationVariants.configureEach { variant -> - // We want the same version stream for all ABIs in debug but for release we can split them - if (variant.buildType.name == 'release') { - variant.outputs.configureEach { output -> - - // For each separate APK per architecture, set a unique version code as described here: - // https://developer.android.com/studio/build/configure-apk-splits.html - def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] - def outputFile = output.outputFile - if (outputFile != null && outputFile.name.endsWith('.apk')) { - def abi = output.getFilter("ABI") - if (abi != null) { // null for the universal-debug, universal-release variants - // From: https://developer.android.com/studio/publish/versioning#appversioning - // "Warning: The greatest value Google Play allows for versionCode is 2100000000" - // AnkiDroid versionCodes have a budget 8 digits (through AnkiDroid 9) - // This style does ABI version code ranges with the 9th digit as 0-4. - // This consumes ~20% of the version range space, w/50 years of versioning at our major-version pace - output.versionCodeOverride = - // ex: 321200106 = 3 * 100000000 + 21200106 - versionCodes.get(abi) * 100000000 + defaultConfig.versionCode - } - } - } - } - } - - testOptions { - animationsDisabled = true - unitTests { - all { test -> - test.useJUnitPlatform { - // ./gradlew testFullDebugUnitTest -Pscreenshot - if (project.hasProperty("screenshot")) { - includeTags "com.ichi2.anki.ScreenshotTestCategory" - - // ./gradlew :AnkiDroid:recordRoborazziPlayDebug -Pscreenshot -Ptheme=black -Pdevice=tablet,foldable - def theme = project.findProperty("theme") ?: "light" - def device = project.findProperty("device") ?: "phone" - def validateInput = { Object input, List validOptions -> - def requested = input.toString().split(',').collect { it.trim().toLowerCase() } - def invalid = requested - validOptions - if (!invalid.isEmpty()) { - throw new GradleException("Invalid param: '${invalid.join(', ')}'. Valid options: ${validOptions.join(', ')}.") - } - } - validateInput(theme, ["light", "plain", "dark", "black", "eink", "all"]) - validateInput(device, ["phone", "tablet", "foldable", "desktop", "all"]) - - systemProperty "screenshot.theme", theme - systemProperty "screenshot.device", device - } else { - excludeTags "com.ichi2.anki.ScreenshotTestCategory" - } - // Ensures that no EmptyApplication test relies on AnkiDroidApp - // by only running EmptyApplication tests - // ./gradlew testFullDebugUnitTest -PemptyApplication - if (project.hasProperty("emptyApplication")) { - includeTags "com.ichi2.anki.EmptyApplicationCategory" - } - } - } - } - } - - // https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures - // https://developer.android.com/build/releases/agp-7-2-0-release-notes#test-fixtures - // ⚠️ There was an in-IDE warning: "Kotlin is not configured" when editing the testFixtures - // files. I ended up ignoring the warning after the 'Configure' button in Android Studio - // added dependencies but didn't fix the issue. - testFixtures { - enable = true - } - - compileOptions { - coreLibraryDesugaringEnabled = true - } - - packagingOptions { - resources { - excludes += ['META-INF/DEPENDENCIES'] - } - } -} - -// register release tasks using the finalized version configuration. -androidComponents.finalizeDsl { androidDsl -> - // output versionName etc... to `build/outputs/release-metadata.json` for use in CI - tasks.register('writeReleaseMetadata', WriteReleaseMetadata) { - group = 'release' - description = 'Writes the configured release version and type as JSON.' - versionName.set(androidDsl.defaultConfig.versionName) - versionCode.set(androidDsl.defaultConfig.versionCode) - } - - // register `validateVersionCode` with the finalized version code. - def validation = VersionCodeTasks.registerValidationTask(project, androidDsl.defaultConfig.versionCode) - // handle all 'publish' calls, even if run directly - tasks.matching { it.name.startsWith("publish") }.configureEach { - dependsOn(validation) - } -} - -play { - serviceAccountCredentials.set(file("${homePath}/src/AnkiDroid-GCP-Publish-Credentials.json")) - track.set('alpha') - - // any time we bump minSdk we want Play Store to retain the old artifacts by version code, - // so that they remain available for older devices - retain { - artifacts.set([ - 20700300L, // (2.7, minSdk 10, universal APK) - 20804300L, // (2.8.4, minSdk 10, universal APK) - 21004300L, // (2.10.4, minSdk 15, universal APK) - // release-2.14 minSdk 16: missing and not re-publishable, see issue 17791 - 121603300L, // (2.16.3, minSdk 21, ABI armeabi-v7a) - 221603300L, // (2.16.3, minSdk 21, ABI x86) - 321603300L, // (2.16.3, minSdk 21, ABI arm64-v8a) - 421603300L, // (2.16.3, minSdk 21, ABI x86_64) - 121905300L, // (2.19.5, minSdk 23, ABI armeabi-v7a) - 221905300L, // (2.19.5, minSdk 23, ABI x86) - 321905300L, // (2.19.5, minSdk 23, ABI arm64-v8a) - 421905300L, // (2.19.5, minSdk 23, ABI x86_64) - ]) - } - - // If you retain APKs in a release with different names as we do above, - // the plugin + Play Store has no idea how to name the release except by date. - // release name is developer only, but sane names really help, so set one - releaseName.set(android.defaultConfig.versionName) -} - -// Install Git pre-commit hook for Ktlint -// Resolve via git so worktrees (where `.git` is a file) are handled correctly. -def gitHooksDir = providers.exec { - workingDir = rootProject.rootDir - commandLine "git", "rev-parse", "--git-path", "hooks" -}.standardOutput.asText.map { rootProject.rootDir.toPath().resolve(it.trim()).toFile() } - -tasks.register('installGitHook', Copy) { - from new File(rootProject.rootDir, 'pre-commit') - into gitHooksDir - filePermissions { - user { - read = write = execute = true - } - } -} -// to run manually: `./gradlew installGitHook` -tasks.named('preBuild').configure { dependsOn('installGitHook') } - -// Fail fast when an emulator's primary user (user 0) is still locked after boot. -// -// Purpose: detect Play / Google-apis AVDs that have an account with a startup PIN or -// screen lock. Those report sys.boot_completed while user 0 stays RUNNING_LOCKED, so -// credential-encrypted storage is unavailable, AnkiDroidApp.onCreate crashes reading -// preferences, and the connected suite reports a confusing zero-test "success" after -// minutes of work. This check stops that before install. -// -// Scope: -// - Only user 0 (secondary profiles being locked must not fail the build). -// - Device set and selector precedence exactly match AGP connected tests: -// task --serial, connectedCheck --serial, ANDROID_SERIAL, then every online device. -abstract class AssertDevicesUnlocked extends DefaultTask { - @InputFile - abstract RegularFileProperty getAdbExecutable() - - /** --serial values supplied directly to connectedPlayAndroidTest. */ - @Input - abstract ListProperty getDirectSerials() - - /** --serial values inherited from connectedCheck. */ - @Input - abstract ListProperty getConnectedCheckSerials() - - /** ANDROID_SERIAL, tracked as task input for configuration-cache correctness. */ - @Input - @Optional - abstract Property getAndroidSerialEnv() - - @javax.inject.Inject - abstract ExecOperations getExecOperations() - - @TaskAction - void assertDevicesUnlocked() { - def adb = adbExecutable.get().asFile.absolutePath - def selection = selectSerials( - directSerials.present, - directSerials.getOrElse([]), - connectedCheckSerials.getOrElse([]), - androidSerialEnv.getOrNull(), - ) - def onlineSerials = [] - for (String line : adbOutput(adb, 'devices').readLines()) { - if (!(line ==~ /\S+\tdevice/)) { - continue - } - onlineSerials.add(line.split('\t')[0]) - } - - if (!onlineSerials) { - throw new GradleScriptException("No online devices found.", null) - } - - def serialsToCheck = onlineSerials - if (selection.serials != null) { - // Match AGP's intended multiset behavior for task-option lists: each online device - // consumes one requested serial. Repeating the same --serial twice therefore leaves - // one unmatched and fails. - // - // AGP 9.4 implements that by mutating targetSerials in - // DeviceProviderInstrumentTestTask.getFilteredDevices(). That list comes from - // ListProperty.get() (an ImmutableList), so connectedPlay*AndroidTest --serial - // currently crashes with UnsupportedOperationException at ImmutableCollection.remove - // before tests run. Reproduced on this repo with and without this preflight; no - // public Issue Tracker ID found as of 2026-09-22. ANDROID_SERIAL still works because - // that path never calls getFilteredDevices(). This preflight copies into an ArrayList - // so we do not inherit the same crash. - def unmatched = new ArrayList(selection.serials) - for (String onlineSerial : onlineSerials) { - unmatched.remove(onlineSerial) - } - if (unmatched) { - throw new GradleScriptException( - selection.source + " lists device(s) not connected (or not in 'device' state): " + - unmatched.join(', ') + ". Connected: " + onlineSerials.join(', ') + ".", - null, - ) - } - serialsToCheck = selection.serials.unique() - } - - logger.info("Checking user-0 unlock state on ${serialsToCheck} selected by ${selection.source}") - def locked = [] - for (String serial : serialsToCheck) { - if (isUser0Locked(adbOutput(adb, '-s', serial, 'shell', 'dumpsys', 'user'))) { - locked.add(serial) - } - } - if (locked) { - throw new GradleScriptException( - "Device(s) " + locked.join(', ') + " have booted but user 0 is still locked, so every " + - "test would fail in Application.onCreate. Unlock the screen (a Play AVD with an " + - "account + startup PIN stays locked until the PIN is entered), or select an unlocked " + - "device with --serial / ANDROID_SERIAL.", - null, - ) - } - } - - /** - * Select serials using AGP DeviceProviderInstrumentTestTask.getTargetSerials() + run(). - * - * Presence of the direct --serial ListProperty wins even when the list is empty: AGP then - * skips connectedCheck --serial and consults ANDROID_SERIAL only because the target list is - * empty. Groovy-truth on the list contents would wrongly fall through to connectedCheck. - * ANDROID_SERIAL is Splitter.on(',').split with no trim (ConnectedDeviceProvider). - * A null serial list means AGP will run against every online device. - */ - static Map selectSerials( - boolean directSerialsPresent, - List directSerials, - List connectedCheckSerials, - String androidSerialEnv - ) { - def targetSerials = directSerialsPresent ? directSerials : connectedCheckSerials - def source = directSerialsPresent ? 'Task --serial' : 'connectedCheck --serial' - if (targetSerials) { - return [source: source, serials: targetSerials] - } - if (androidSerialEnv != null && !androidSerialEnv.isEmpty()) { - // AGP uses Sets.newHashSet(Splitter.on(',').split(androidSerialEnv)). - def serials = androidSerialEnv.split(',', -1).toList().unique() - return [source: 'ANDROID_SERIAL', serials: serials] - } - return [source: 'connected devices', serials: null] - } - - /** - * True when dumpsys user reports user 0 as locked. - * Handles both modern dumps (`Started users state: [0=RUNNING_UNLOCKED]`) and Android 11 - * dumps (`Started users state: {0=1, 10=3}`), where UserState.STATE_RUNNING_LOCKED = 1 and - * STATE_RUNNING_UNLOCKED = 3 (frameworks/base/.../am/UserState.java). Match the user-0 entry - * exactly so a locked secondary profile (e.g. 10=RUNNING_LOCKED / 10=1) cannot false-positive, - * including via the "0=RUNNING_LOCKED" substring inside "10=RUNNING_LOCKED". - */ - static boolean isUser0Locked(String dumpsysUser) { - def matcher = dumpsysUser =~ /Started users state: [\[{]([^\]}]*)[\]}]/ - if (!matcher.find()) { - return false - } - for (String entry : matcher.group(1).split(',')) { - def parts = entry.trim().split('=', 2) - if (parts.length != 2 || parts[0] != '0') { - continue - } - def state = parts[1] - // Numeric (API 30 dumps) or named (newer dumps). STATE_RUNNING_LOCKED = 1. - return state == 'RUNNING_LOCKED' || state == '1' - } - return false - } - - private String adbOutput(String adb, String... args) { - def output = new ByteArrayOutputStream() - execOperations.exec { - it.commandLine([adb, *args]) - it.standardOutput = output - } - return output.toString() - } -} -tasks.register('assertDevicesUnlocked', AssertDevicesUnlocked) { - adbExecutable.set(androidComponents.sdkComponents.adb) - // No convention([]) on the --serial lists: AGP getTargetSerials() keys off isPresent(), - // and an empty convention would make the direct property look intentionally set. - androidSerialEnv.set(providers.environmentVariable('ANDROID_SERIAL')) - // Device state is not an input Gradle can track, so never consider this task up to date - outputs.upToDateWhen { false } -} - -// Issue 11078 - some emulators run, but run zero tests, and still report success -tasks.register('assertNonzeroAndroidTests') { - // Resolve the directory at configuration time, which is Gradle Configuration Cache compatible. - // The build type segment must track the variant the connected test task runs under, so it is - // derived from the same property that names that task. - def buildType = rootProject.androidTestVariantName.toLowerCase(Locale.ROOT) - def resultsDir = layout.buildDirectory.dir("outputs/androidTest-results/connected/${buildType}/flavors/play") - doLast { - // androidTest currently creates one .xml file per emulator with aggregate results in this dir - File folder = resultsDir.get().asFile - File[] listOfFiles = folder.listFiles({ d, f -> f ==~ /.*.xml/ } as FilenameFilter) - // No results at all is the same failure this guard exists to catch, and it is how the guard - // itself goes unnoticed if the results layout moves again - if (!listOfFiles) { - throw new GradleScriptException("No androidTest result files in " + folder + " - the connected test run reported success without producing results.", null) - } - for (File file : listOfFiles) { - // The root element carries the aggregate count for one device, across any number of - // nested per-class elements - def declaredTests = new groovy.xml.XmlSlurper().parse(file).@tests.text() - if (!declaredTests.isInteger()) { - throw new GradleScriptException("Unable to determine count of tests executed for " + file.name + ". Results format changed?", null) - } - if (declaredTests.toInteger() == 0) { - throw new GradleScriptException("androidTest executed 0 tests for " + file.name + " - Probably a bug with the emulator. Try another image.", null) - } - } - } -} -afterEvaluate { - def connectedTest = tasks.named("connectedPlay${rootProject.androidTestVariantName}AndroidTest").get() - // Keep the preflight device set in lockstep with AGP's connected-test selection. The nested - // properties include task --serial and connectedCheck --serial after Gradle parses command-line - // task options; AGP applies the same precedence in getTargetSerials(). - // - // Reading these properties does not cause AGP's --serial crash. That crash is inside AGP 9.4 - // DeviceProviderInstrumentTestTask.getFilteredDevices(), which calls List.remove on the - // ImmutableList returned by ListProperty.get(). Reproduced without this wiring; no public - // tracker ID found as of 2026-09-22. Use ANDROID_SERIAL until AGP copies the list first. - tasks.named('assertDevicesUnlocked').configure { - directSerials.set(connectedTest.testRunnerFactory.deviceSerialValues) - connectedCheckSerials.set(connectedTest.testRunnerFactory.connectedCheckDeviceSerials) - } - connectedTest.dependsOn('assertDevicesUnlocked') - connectedTest.finalizedBy('assertNonzeroAndroidTests') - - // AGP bug: when testFixtures is enabled on an application module, the androidTest R8 task's - // TESTED_CODE scope only includes the app's dependency artifacts, not the app's own compiled - // classes. This causes R8 to report "Missing class" errors for all app classes referenced from - // androidTest code. Work around by adding the app classes as referenced (library) input. - if (testReleaseBuild) { - tasks.named("minifyPlayReleaseAndroidTestWithR8").configure { - referencedClasses.from( - files("build/intermediates/compile_app_classes_jar/playRelease/bundlePlayReleaseClassesToCompileJar/classes.jar") - ) - } - } -} - -apply from: "./robolectricDownloader.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 { - // Timber has this as a dependency but they are not up to date. We want to force our version. - force libs.jetbrains.annotations - } - } - api project(":api") - implementation libs.androidx.work.runtime - lintChecks project(":lint-rules") - coreLibraryDesugaring libs.desugar.jdk.libs.nio - - compileOnly libs.jetbrains.annotations - compileOnly libs.auto.service.annotations - annotationProcessor libs.auto.service - - // modules - implementation project(":anki-common") - implementation project(":common") - implementation project(":common:android") - implementation project(":compat") - implementation project(":libanki") - implementation project(":vbpd") - "baselineProfile" project(":baselineprofile") - implementation project(":widgets") - - implementation(libs.google.analytics.kt) - - implementation libs.androidx.activity - // TODO: hoist the Compose enablement (kotlin.compose plugin, buildFeatures.compose, - // and the dependencies below) into a convention plugin under `buildSrc/` once the - // Kotlin-DSL migration of this build script lands - implementation libs.androidx.activity.compose - implementation platform(libs.androidx.compose.bom) - implementation libs.androidx.compose.ui - implementation libs.androidx.compose.ui.tooling.preview - implementation libs.androidx.compose.material3 - implementation libs.androidx.lifecycle.viewmodel.compose - implementation libs.androidx.lifecycle.runtime.compose - debugImplementation libs.androidx.compose.ui.tooling - implementation libs.androidx.annotation - implementation libs.androidx.appcompat - implementation libs.androidx.browser - implementation libs.androidx.core.ktx - implementation libs.androidx.core.remoteviews - implementation libs.androidx.draganddrop - implementation libs.androidx.exifinterface - implementation libs.androidx.fragment.ktx - implementation libs.androidx.lifecycle.process - implementation libs.androidx.preference.ktx - implementation libs.androidx.profileinstaller - implementation libs.androidx.recyclerview - implementation libs.androidx.sqlite.framework - implementation libs.androidx.swiperefreshlayout - implementation libs.androidx.viewpager2 - implementation libs.androidx.constraintlayout - implementation libs.androidx.webkit - implementation libs.google.material - implementation libs.android.image.cropper - implementation libs.nanohttpd - implementation libs.kotlinx.serialization.json - implementation libs.seismic - - debugImplementation libs.androidx.fragment.testing.manifest - - // Backend libraries - - implementation libs.protobuf.kotlin.lite // This is required when loading from a file - - // TODO: migrate to `addAnkiBackendDependencies(...)` when this is a gradle.kts file - Properties localProperties = new Properties() - if (project.rootProject.file('local.properties').exists()) { - localProperties.load(project.rootProject.file('local.properties').newDataInputStream()) - } - if (localProperties['local_backend'] == "true") { - implementation(files(rootProject.file("../Anki-Android-Backend/rsdroid/build/outputs/aar/rsdroid-release.aar"))) - testImplementation(files(rootProject.file("../Anki-Android-Backend/rsdroid-testing/build/libs/rsdroid-testing.jar"))) - } else { - implementation libs.ankiBackend.backend - testImplementation libs.ankiBackend.testing - } - - // May need a resolution strategy for support libs to our versions - implementation libs.acra.limiter - implementation libs.acra.toast - implementation libs.acra.dialog - implementation libs.acra.http - - implementation libs.commons.compress - implementation libs.commons.collections4 // SetUniqueList - implementation libs.commons.io // FileUtils.contentEquals - implementation libs.okhttp - implementation libs.slf4j.timber - implementation libs.jakewharton.timber - implementation libs.jsoup - implementation libs.java.semver // For AnkiDroid JS API Versioning - implementation libs.drakeet.drawer - implementation libs.skydoves.colorpickerview - implementation libs.kotlin.reflect - implementation libs.kotlin.test - implementation libs.search.preference - - // Cannot use debugImplementation since classes need to be imported in AnkiDroidApp - // and there's no no-op version for release build. Usage has been disabled for release - // build via AnkiDroidApp. - implementation libs.leakcanary.android - - testImplementation testFixtures(project(":libanki")) - testImplementation testFixtures(project(":common")) - testImplementation testFixtures(project(":common:android")) - testImplementation testFixtures(project(":compat")) - testImplementation testFixtures(project(":anki-common")) - androidTestImplementation testFixtures(project(":anki-common")) - - // A path for a testing library which provide Parameterized Test - testImplementation libs.junit.jupiter - testImplementation libs.junit.jupiter.params - testImplementation libs.junit.vintage.engine - testImplementation libs.mockito.inline - testImplementation libs.mockito.kotlin - testImplementation libs.hamcrest - testImplementation libs.test.parameter.injector - // robolectricDownloader.gradle *may* need a new SDK jar entry if they release one or if we change targetSdk. Instructions in that gradle file. - testImplementation libs.robolectric - testImplementation libs.androidx.compose.ui.test.junit4 - // Needed for createComposeRule(), but not for createAndroidComposeRule() - debugImplementation libs.androidx.compose.ui.test.manifest - testImplementation libs.androidx.test.core - testImplementation libs.androidx.test.junit - testImplementation libs.kotlin.reflect - testImplementation libs.kotlin.test - testImplementation libs.kotlin.test.junit5 - testImplementation libs.kotlinx.coroutines.test - testImplementation libs.mockk - testImplementation libs.androidx.fragment.testing - // in a JvmTest we need org.json.JSONObject to not be mocked - testImplementation libs.json - testImplementation libs.ivanshafran.shared.preferences.mock - testImplementation libs.androidx.test.runner - testImplementation libs.androidx.test.rules - testImplementation libs.androidx.espresso.core - testImplementation(libs.androidx.espresso.contrib) { - exclude module: "protobuf-lite" - } - testImplementation libs.androidx.work.testing - testImplementation libs.roborazzi - testImplementation libs.roborazzi.rule - // for testing flows - testImplementation libs.cashapp.turbine - - androidTestImplementation testFixtures(project(":anki-common")) - - // May need a resolution strategy for support libs to our versions - androidTestImplementation libs.androidx.espresso.core - androidTestImplementation(libs.androidx.espresso.contrib) { - exclude module: "protobuf-lite" - } - androidTestImplementation libs.androidx.test.core - androidTestImplementation libs.androidx.test.junit - androidTestImplementation libs.androidx.test.rules - androidTestImplementation libs.androidx.uiautomator - androidTestImplementation libs.kotlin.test - androidTestImplementation libs.kotlin.test.junit - androidTestImplementation libs.androidx.fragment.testing - - implementation libs.androidx.media3.exoplayer - implementation libs.androidx.media3.exoplayer.dash - implementation libs.androidx.media3.ui - - // ---- testFixtures setup ---- - testFixturesImplementation libs.kotlin.stdlib - testFixturesImplementation libs.jakewharton.timber - testFixturesImplementation libs.hamcrest - testFixturesImplementation libs.androidx.test.junit - // Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause - // an "unresolved" warning, which is treated as an error due to allWarningsAsErrors - testFixturesImplementation libs.kotlinx.coroutines.core - // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the - // module including testFixtures, which has no @Composable code and refuses to - // run unless the Compose Runtime is on the classpath. compileOnly satisfies the - // plugin's version check without shipping the runtime in the testFixtures output. - testFixturesCompileOnly platform(libs.androidx.compose.bom) - testFixturesCompileOnly libs.androidx.compose.runtime - -} diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts new file mode 100644 index 000000000000..6b1f8a0107ff --- /dev/null +++ b/AnkiDroid/build.gradle.kts @@ -0,0 +1,938 @@ +// Keep the existing AGP variant/task APIs until the separate AGP DSL migration. +@file:Suppress("DEPRECATION") + +import com.ichi2.anki.gradle.VersionCodeTasks +import com.ichi2.anki.gradle.WriteReleaseMetadata +import java.io.ByteArrayOutputStream +import java.util.Locale +import java.util.Properties +import java.util.regex.Pattern + +plugins { + // Gradle plugin portal + alias(libs.plugins.tripletPlay) + id("ankidroid.android.app") + id("ankidroid.plugins.jacoco") + alias(libs.plugins.androidx.baselineprofile) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.keeper) + alias(libs.plugins.roborazzi) + id("idea") +} + +repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } +} + +keeper { + traceReferences { + // Silence missing definitions + arguments.set(listOf("--map-diagnostics:MissingDefinitionsDiagnostic", "error", "none")) + } +} + +baselineProfile { + // Write generated profiles to `src/main/baselineProfiles/` instead of the + // default per-variant `src/playRelease/generated/baselineProfiles/`. This + // ensures the profile is consumed by ALL variants (play, amazon, full) and + // ALL buildTypes (release, benchmark), not just `playRelease`. + mergeIntoMain = true +} + +idea { + module { + isDownloadJavadoc = System.getenv("CI") != "true" + isDownloadSources = System.getenv("CI") != "true" + } +} + +val testReleaseBuild = rootProject.extra["testReleaseBuild"] as Boolean +val universalApkEnabled = rootProject.extra["universalApkEnabled"] as Boolean +val androidTestVariantName = rootProject.extra["androidTestVariantName"] as String + +val homePath = System.getProperty("user.home") + +/** + * Calculates the current git hash, invalidates configuration cache if changed + * @example edf739d95bad7b370a6ed4398d46723f8219b3cd + * @see https://docs.gradle.org/current/userguide/configuration_cache_requirements.html#config_cache:requirements:external_processes + */ +val gitCommitHash = + providers + .exec { + commandLine("git", "rev-parse", "HEAD") + }.standardOutput.asText + .map { it.trim() } + +/** + * Epoch millis of the build. Displayed on the 'About' screen. + * + * CI passes the real time via -PbuildTime= # example: 1776422957 + * + * A local build uses a stale value (captured in the configuration cache) so the configuration cache + * is not invalidated on every build. + */ +val buildTimeMillis = + providers + .gradleProperty("buildTime") + .orElse(providers.provider { System.currentTimeMillis().toString() }) + +android { + namespace = "com.ichi2.anki" + + buildFeatures { + buildConfig = true + aidl = true + viewBinding = true + resValues = true + compose = true + } + + if (testReleaseBuild) { + testBuildType = "release" + } else { + testBuildType = "debug" + } + + defaultConfig { + applicationId = "com.ichi2.anki" + buildConfigField("Boolean", "CI", (System.getenv("CI") == "true").toString()) + buildConfigField("String", "ACRA_URL", "\"https://ankidroid.org/acra/report\"") + buildConfigField("String", "BACKEND_VERSION", "\"${libs.versions.ankiBackend.get()}\"") + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", "false") + buildConfigField("String", "GIT_COMMIT_HASH", "\"${gitCommitHash.get()}\"") + buildConfigField("long", "BUILD_TIME", buildTimeMillis.get()) + // whether UI elements linking to external donation pages are shown ('play' hides them) + buildConfigField("Boolean", "SHOW_DONATE_LINKS", "true") + buildConfigField("int", "ANKIQUEST_RELEASE", (project.findProperty("ankiquestRelease") ?: "0").toString()) + buildConfigField("int", "ANKIQUEST_NIGHTLY", (project.findProperty("ankiquestNightly") ?: "0").toString()) + resValue("string", "app_name", "AnkiDroid") + + // The version number is of the form: + // ..[dev|alpha|beta|] + // The is only present for alpha and beta releases (e.g., 2.0.4alpha2 or 2.0.4beta4), developer builds do + // not have a build number (e.g., 2.0.4dev) and official releases only have three components (e.g., 2.0.4). + // + // The version code is derived from the version name as follows: + // AbbCCtDD + // A: 1-digit decimal number representing the major version + // bb: 2-digit decimal number representing the minor version + // CC: 2-digit decimal number representing the maintenance version + // t: 1-digit decimal number representing the type of the build + // 0: developer build + // 1: alpha release + // 2: beta release + // 3: public release + // DD: 2-digit decimal number representing the build + // 00 for internal builds and public releases + // alpha/beta build number for alpha/beta releases + // + // 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 = 22600102 + // 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.0alpha2" + 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, + // so both channels install over each other in order. + // Assigned through `this.`, so upstream's validateVersionCode still sees one versionCode literal. + if ((project.findProperty("customSuffix") ?: "").toString().replaceFirst(Regex("^\\.*"), "") == "quest") { + val questRelease = (project.findProperty("ankiquestRelease") ?: "0").toString() + val questNightly = (project.findProperty("ankiquestNightly") ?: "0").toString() + if (!questRelease.matches(Regex("[0-9]{1,5}")) || questRelease.toInt() > 49999) { + throw GradleException("ankiquestRelease must be a release number from 0 to 49999") + } + if (!questNightly.matches(Regex("[0-9]{1,3}"))) { + throw GradleException("ankiquestNightly must be a commit count from 0 to 999") + } + if (questRelease.toInt() > 0) { + this.versionCode = 50000000 + questRelease.toInt() * 1000 + questNightly.toInt() + } + } + + // GA4 Measurement Protocol api_secret. Sourced from `local.properties` + // (ANALYTICS_API_KEY) or the ANALYTICS_API_KEY env var; falls back to + // a dummy value so contributor builds still compile. + // + // The dummy key is rejected by GA's ingest, so analytics requests from + // unsigned/local builds go nowhere this means `setDevMode()` (100% + // sampling) has no observable effect on a build without the real key. + // + // Google documents the api_secret as private, and the Measurement Protocol + // as intended for trusted/server-side use: + // https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events#api_secret + // Shipping it in the APK means anyone can extract it and post junk events to + // the property, which would corrupt our reporting. We accept that: there is no + // server-side path for an offline-first app, the property holds no user data, + // and a polluted property can be rotated by issuing a new secret. + val properties = Properties() + val localPropsFile = project.rootProject.file("local.properties") + if (localPropsFile.exists()) { + properties.load(localPropsFile.inputStream()) + } + + val apiKey = + properties.getProperty("ANALYTICS_API_KEY")?.takeIf { it.isNotEmpty() } + ?: System.getenv("ANALYTICS_API_KEY")?.takeIf { it.isNotEmpty() } ?: "DUMMY_API_XXX" + // Keep credentials out of BuildConfig: ACRA collects its fields through reflection. + resValue("string", "ga_api_secret", apiKey) + } + signingConfigs { + create("release") { + val keystorePath = System.getenv("KEYSTOREPATH") + if (keystorePath != null && !keystorePath.trim().isEmpty()) { + storeFile = file(keystorePath) + storePassword = System.getenv("KEYSTOREPWD")?.takeIf { it.isNotEmpty() } ?: System.getenv("KSTOREPWD") + keyAlias = System.getenv("KEYALIAS") + keyPassword = System.getenv("KEYPWD") + } else { + storeFile = file("$rootDir/tools/fallback-release-keystore.jks") + storePassword = "Test@123" + keyAlias = "my-key" + keyPassword = "Test@123" + } + } + } + buildTypes { + named("debug") { + versionNameSuffix = "-debug" + isDebuggable = true + applicationIdSuffix = ".debug" + splits.abi.isUniversalApk = true // Build universal APK for debug always + // Check Crash Reports page on developer wiki for info on ACRA testing + // buildConfigField "String", "ACRA_URL", '"https://918f7f55-f238-436c-b34f-c8b5f1331fe5-bluemix.cloudant.com/acra-ankidroid/_design/acra-storage/_update/report"' + if (project.rootProject.file("local.properties").exists()) { + val localProperties = Properties() + localProperties.load(project.rootProject.file("local.properties").inputStream()) + // #6009 Allow optional disabling of JaCoCo for general build (assembleDebug). + // jacocoDebug task was slow, hung, and wasn't required unless I wanted coverage + isTestCoverageEnabled = localProperties["enable_coverage"] != "false" + // not profiled: optimization for build times + if (localProperties["enable_languages"] == "false") { + android.defaultConfig.resourceConfigurations += "en" + } + // allow disabling leak canary + if (localProperties["enable_leak_canary"] != null) { + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", localProperties.getProperty("enable_leak_canary")) + } else { + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", "true") + } + } else { + isTestCoverageEnabled = true + } + + // make the icon red if in debug mode + resValue("color", "anki_foreground_icon_color_0", "#FFFF0000") + resValue("color", "anki_foreground_icon_color_1", "#FFFF0000") + } + named("release") { + isTestCoverageEnabled = testReleaseBuild + isMinifyEnabled = System.getenv("MINIFY_ENABLED").let { if (!it.isNullOrEmpty()) it != "false" else true } + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + testProguardFile("proguard-test-rules.pro") + splits.abi.isUniversalApk = universalApkEnabled // Build universal APK for release with `-Duniversal-apk=true` + signingConfig = signingConfigs.getByName("release") + + // syntax: assembleRelease -PcustomSuffix="suffix" -PcustomName="New name" + if (project.hasProperty("customSuffix")) { + // the suffix needs a '.' at the start + applicationIdSuffix = project.property("customSuffix").toString().replaceFirst(Regex("^\\.*"), ".") + } + if (project.hasProperty("customName")) { + resValue("string", "app_name", project.property("customName").toString()) + } + + resValue("color", "anki_foreground_icon_color_0", "#FF29B6F6") + resValue("color", "anki_foreground_icon_color_1", "#FF0288D1") + } + + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + isDebuggable = false + } + } + + /* + * Product Flavors are used for Amazon App Store and Google Play Store. + * This is because we cannot use Camera Permissions in Amazon App Store (for FireTv etc...) + * Therefore, different AndroidManifest for Camera Permissions is used in Amazon flavor. + */ + flavorDimensions += "appStore" + productFlavors { + create("play") { + isDefault = true + dimension = "appStore" + // Google Play no longer considers Open Collective to be acceptable for + // a 'tax-exempt' link. Temporarily remove our links + buildConfigField("Boolean", "SHOW_DONATE_LINKS", "false") + } + create("amazon") { + dimension = "appStore" + } + // A 'full' build has no restrictions on storage/camera. Distributed on GitHub/F-Droid + create("full") { + dimension = "appStore" + } + } + + /** + * Set this to true to create five separate APKs instead of one: + * - 2 APKs that only work on ARM/ARM64 devices + * - 2 APKs that only works on x86/x86_64 devices + * - a universal APK that works on all devices + * The advantage is the size of most APKs is reduced by about 2.5MB. + * Upload all the APKs to the Play Store and people will download + * the correct one based on the CPU architecture of their device. + */ + val enableSeparateBuildPerCPUArchitecture = true + + /** + * Restricts the build to a single CPU architecture, e.g. `-PabiFilter=arm64-v8a`. + * Allows F-Droid to build one APK per architecture without patching this file (#21399). + */ + val abiFilter = providers.gradleProperty("abiFilter").getOrNull() + val supportedAbis = listOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64") + if (abiFilter != null && !supportedAbis.contains(abiFilter)) { + throw GradleException("Unsupported -PabiFilter value '$abiFilter'. Expected one of: $supportedAbis") + } + + splits { + abi { + reset() + isEnable = enableSeparateBuildPerCPUArchitecture + // universalApk enableUniversalApk // set in debug + release config blocks above + if (abiFilter != null) { + include(abiFilter) + } else { + include("armeabi-v7a", "x86", "arm64-v8a", "x86_64") + } + } + } + // applicationVariants are e.g. debug, release + applicationVariants.configureEach { + val variant = this + // We want the same version stream for all ABIs in debug but for release we can split them + if (variant.buildType.name == "release") { + variant.outputs.configureEach { + val output = this as com.android.build.gradle.internal.api.ApkVariantOutputImpl + + // For each separate APK per architecture, set a unique version code as described here: + // https://developer.android.com/studio/build/configure-apk-splits.html + val versionCodes = mapOf("armeabi-v7a" to 1, "x86" to 2, "arm64-v8a" to 3, "x86_64" to 4) + val outputFile = output.outputFile + if (outputFile != null && outputFile.name.endsWith(".apk")) { + val abi = output.getFilter("ABI") + if (abi != null) { // null for the universal-debug, universal-release variants + // From: https://developer.android.com/studio/publish/versioning#appversioning + // "Warning: The greatest value Google Play allows for versionCode is 2100000000" + // AnkiDroid versionCodes have a budget 8 digits (through AnkiDroid 9) + // This style does ABI version code ranges with the 9th digit as 0-4. + // This consumes ~20% of the version range space, w/50 years of versioning at our major-version pace + output.versionCodeOverride = + // ex: 321200106 = 3 * 100000000 + 21200106 + versionCodes.getValue(abi) * 100000000 + defaultConfig.versionCode!! + } + } + } + } + } + + testOptions { + animationsDisabled = true + unitTests { + all { test -> + test.useJUnitPlatform { + // ./gradlew testFullDebugUnitTest -Pscreenshot + if (project.hasProperty("screenshot")) { + includeTags("com.ichi2.anki.ScreenshotTestCategory") + + // ./gradlew :AnkiDroid:recordRoborazziPlayDebug -Pscreenshot -Ptheme=black -Pdevice=tablet,foldable + val theme = project.findProperty("theme")?.takeIf { it.toString().isNotEmpty() } ?: "light" + val device = project.findProperty("device")?.takeIf { it.toString().isNotEmpty() } ?: "phone" + val validateInput = { input: Any, validOptions: List -> + val requested = + Pattern + .compile( + ",", + ).split(input.toString()) + .map { it.trim().lowercase(Locale.getDefault()) } + val invalid = requested - validOptions.toSet() + if (!invalid.isEmpty()) { + throw GradleException( + "Invalid param: '${invalid.joinToString(", ")}'. Valid options: ${validOptions.joinToString(", ")}.", + ) + } + } + validateInput(theme, listOf("light", "plain", "dark", "black", "eink", "all")) + validateInput(device, listOf("phone", "tablet", "foldable", "desktop", "all")) + + test.systemProperty("screenshot.theme", theme) + test.systemProperty("screenshot.device", device) + } else { + excludeTags("com.ichi2.anki.ScreenshotTestCategory") + } + // Ensures that no EmptyApplication test relies on AnkiDroidApp + // by only running EmptyApplication tests + // ./gradlew testFullDebugUnitTest -PemptyApplication + if (project.hasProperty("emptyApplication")) { + includeTags("com.ichi2.anki.EmptyApplicationCategory") + } + } + } + } + } + + // https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures + // https://developer.android.com/build/releases/agp-7-2-0-release-notes#test-fixtures + // ⚠️ There was an in-IDE warning: "Kotlin is not configured" when editing the testFixtures + // files. I ended up ignoring the warning after the 'Configure' button in Android Studio + // added dependencies but didn't fix the issue. + testFixtures { + enable = true + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } + + packagingOptions { + resources { + excludes += listOf("META-INF/DEPENDENCIES") + } + } +} + +// register release tasks using the finalized version configuration. +androidComponents.finalizeDsl { androidDsl -> + // output versionName etc... to `build/outputs/release-metadata.json` for use in CI + tasks.register("writeReleaseMetadata") { + group = "release" + description = "Writes the configured release version and type as JSON." + versionName.set(androidDsl.defaultConfig.versionName) + versionCode.set(androidDsl.defaultConfig.versionCode) + } + + // register `validateVersionCode` with the finalized version code. + val validation = VersionCodeTasks.registerValidationTask(project, androidDsl.defaultConfig.versionCode) + // handle all 'publish' calls, even if run directly + tasks.matching { it.name.startsWith("publish") }.configureEach { + dependsOn(validation) + } +} + +play { + serviceAccountCredentials.set(file("$homePath/src/AnkiDroid-GCP-Publish-Credentials.json")) + track.set("alpha") + + // any time we bump minSdk we want Play Store to retain the old artifacts by version code, + // so that they remain available for older devices + retain { + artifacts.set( + listOf( + 20700300L, // (2.7, minSdk 10, universal APK) + 20804300L, // (2.8.4, minSdk 10, universal APK) + 21004300L, // (2.10.4, minSdk 15, universal APK) + // release-2.14 minSdk 16: missing and not re-publishable, see issue 17791 + 121603300L, // (2.16.3, minSdk 21, ABI armeabi-v7a) + 221603300L, // (2.16.3, minSdk 21, ABI x86) + 321603300L, // (2.16.3, minSdk 21, ABI arm64-v8a) + 421603300L, // (2.16.3, minSdk 21, ABI x86_64) + 121905300L, // (2.19.5, minSdk 23, ABI armeabi-v7a) + 221905300L, // (2.19.5, minSdk 23, ABI x86) + 321905300L, // (2.19.5, minSdk 23, ABI arm64-v8a) + 421905300L, // (2.19.5, minSdk 23, ABI x86_64) + ), + ) + } + + // If you retain APKs in a release with different names as we do above, + // the plugin + Play Store has no idea how to name the release except by date. + // release name is developer only, but sane names really help, so set one + releaseName.set(android.defaultConfig.versionName) +} + +// Install Git pre-commit hook for Ktlint +// Resolve via git so worktrees (where `.git` is a file) are handled correctly. +val gitHooksDir = + providers + .exec { + workingDir = rootProject.rootDir + commandLine("git", "rev-parse", "--git-path", "hooks") + }.standardOutput.asText + .map { + rootProject.rootDir + .toPath() + .resolve(it.trim()) + .toFile() + } + +tasks.register("installGitHook") { + from(File(rootProject.rootDir, "pre-commit")) + into(gitHooksDir) + filePermissions { + user { + read = true + write = true + execute = true + } + } +} +// to run manually: `./gradlew installGitHook` +tasks.named("preBuild").configure { dependsOn("installGitHook") } + +// Fail fast when an emulator's primary user (user 0) is still locked after boot. +// +// Purpose: detect Play / Google-apis AVDs that have an account with a startup PIN or +// screen lock. Those report sys.boot_completed while user 0 stays RUNNING_LOCKED, so +// credential-encrypted storage is unavailable, AnkiDroidApp.onCreate crashes reading +// preferences, and the connected suite reports a confusing zero-test "success" after +// minutes of work. This check stops that before install. +// +// Scope: +// - Only user 0 (secondary profiles being locked must not fail the build). +// - Device set and selector precedence exactly match AGP connected tests: +// task --serial, connectedCheck --serial, ANDROID_SERIAL, then every online device. +abstract class AssertDevicesUnlocked : DefaultTask() { + @get:InputFile + abstract val adbExecutable: RegularFileProperty + + /** --serial values supplied directly to connectedPlayAndroidTest. */ + @get:Input + abstract val directSerials: ListProperty + + /** --serial values inherited from connectedCheck. */ + @get:Input + abstract val connectedCheckSerials: ListProperty + + /** ANDROID_SERIAL, tracked as task input for configuration-cache correctness. */ + @get:Input + @get:Optional + abstract val androidSerialEnv: Property + + @get:javax.inject.Inject + abstract val execOperations: ExecOperations + + @TaskAction + fun assertDevicesUnlocked() { + val adb = adbExecutable.get().asFile.absolutePath + val selection = + selectSerials( + directSerials.isPresent, + directSerials.getOrElse(emptyList()), + connectedCheckSerials.getOrElse(emptyList()), + androidSerialEnv.getOrNull(), + ) + val onlineSerials = mutableListOf() + for (line in adbOutput(adb, "devices").lines()) { + if (!Regex("""\S+\tdevice""").matches(line)) { + continue + } + onlineSerials.add(line.split("\t")[0]) + } + + if (onlineSerials.isEmpty()) { + throw GradleException("No online devices found.") + } + + var serialsToCheck: List = onlineSerials + if (selection.serials != null) { + // Match AGP's intended multiset behavior for task-option lists: each online device + // consumes one requested serial. Repeating the same --serial twice therefore leaves + // one unmatched and fails. + // + // AGP 9.4 implements that by mutating targetSerials in + // DeviceProviderInstrumentTestTask.getFilteredDevices(). That list comes from + // ListProperty.get() (an ImmutableList), so connectedPlay*AndroidTest --serial + // currently crashes with UnsupportedOperationException at ImmutableCollection.remove + // before tests run. Reproduced on this repo with and without this preflight; no + // public Issue Tracker ID found as of 2026-09-22. ANDROID_SERIAL still works because + // that path never calls getFilteredDevices(). This preflight copies into an ArrayList + // so we do not inherit the same crash. + val unmatched = ArrayList(selection.serials) + for (onlineSerial in onlineSerials) { + unmatched.remove(onlineSerial) + } + if (unmatched.isNotEmpty()) { + throw GradleException( + selection.source + " lists device(s) not connected (or not in 'device' state): " + + unmatched.joinToString(", ") + ". Connected: " + onlineSerials.joinToString(", ") + ".", + ) + } + serialsToCheck = selection.serials.distinct() + } + + logger.info("Checking user-0 unlock state on $serialsToCheck selected by ${selection.source}") + val locked = mutableListOf() + for (serial in serialsToCheck) { + if (isUser0Locked(adbOutput(adb, "-s", serial, "shell", "dumpsys", "user"))) { + locked.add(serial) + } + } + if (locked.isNotEmpty()) { + throw GradleException( + "Device(s) " + locked.joinToString(", ") + " have booted but user 0 is still locked, so every " + + "test would fail in Application.onCreate. Unlock the screen (a Play AVD with an " + + "account + startup PIN stays locked until the PIN is entered), or select an unlocked " + + "device with --serial / ANDROID_SERIAL.", + ) + } + } + + data class SerialSelection( + val source: String, + val serials: List?, + ) + + /** + * Select serials using AGP DeviceProviderInstrumentTestTask.getTargetSerials() + run(). + * + * Presence of the direct --serial ListProperty wins even when the list is empty: AGP then + * skips connectedCheck --serial and consults ANDROID_SERIAL only because the target list is + * empty. Groovy-truth on the list contents would wrongly fall through to connectedCheck. + * ANDROID_SERIAL is Splitter.on(',').split with no trim (ConnectedDeviceProvider). + * A null serial list means AGP will run against every online device. + */ + fun selectSerials( + directSerialsPresent: Boolean, + directSerials: List, + connectedCheckSerials: List, + androidSerialEnv: String?, + ): SerialSelection { + val targetSerials = if (directSerialsPresent) directSerials else connectedCheckSerials + val source = if (directSerialsPresent) "Task --serial" else "connectedCheck --serial" + if (targetSerials.isNotEmpty()) { + return SerialSelection(source, targetSerials) + } + if (androidSerialEnv != null && !androidSerialEnv.isEmpty()) { + // AGP uses Sets.newHashSet(Splitter.on(',').split(androidSerialEnv)). + val serials = androidSerialEnv.split(",").distinct() + return SerialSelection("ANDROID_SERIAL", serials) + } + return SerialSelection("connected devices", null) + } + + /** + * True when dumpsys user reports user 0 as locked. + * Handles both modern dumps (`Started users state: [0=RUNNING_UNLOCKED]`) and Android 11 + * dumps (`Started users state: {0=1, 10=3}`), where UserState.STATE_RUNNING_LOCKED = 1 and + * STATE_RUNNING_UNLOCKED = 3 (frameworks/base/.../am/UserState.java). Match the user-0 entry + * exactly so a locked secondary profile (e.g. 10=RUNNING_LOCKED / 10=1) cannot false-positive, + * including via the "0=RUNNING_LOCKED" substring inside "10=RUNNING_LOCKED". + */ + fun isUser0Locked(dumpsysUser: String): Boolean { + val matcher = Regex("""Started users state: [\[{]([^\]}]*)[\]}]""").find(dumpsysUser) + if (matcher == null) { + return false + } + for (entry in matcher.groupValues[1].split(",")) { + val parts = entry.trim().split("=", limit = 2) + if (parts.size != 2 || parts[0] != "0") { + continue + } + val state = parts[1] + // Numeric (API 30 dumps) or named (newer dumps). STATE_RUNNING_LOCKED = 1. + return state == "RUNNING_LOCKED" || state == "1" + } + return false + } + + private fun adbOutput( + adb: String, + vararg args: String, + ): String { + val output = ByteArrayOutputStream() + execOperations.exec { + commandLine(adb, *args) + standardOutput = output + } + return output.toString() + } +} +tasks.register("assertDevicesUnlocked") { + adbExecutable.set(androidComponents.sdkComponents.adb) + // No convention([]) on the --serial lists: AGP getTargetSerials() keys off isPresent(), + // and an empty convention would make the direct property look intentionally set. + androidSerialEnv.set(providers.environmentVariable("ANDROID_SERIAL")) + // Device state is not an input Gradle can track, so never consider this task up to date + outputs.upToDateWhen { false } +} + +// Issue 11078 - some emulators run, but run zero tests, and still report success +tasks.register("assertNonzeroAndroidTests") { + // Resolve the directory at configuration time, which is Gradle Configuration Cache compatible. + // The build type segment must track the variant the connected test task runs under, so it is + // derived from the same property that names that task. + val buildType = androidTestVariantName.lowercase(Locale.ROOT) + val resultsDir = layout.buildDirectory.dir("outputs/androidTest-results/connected/$buildType/flavors/play") + doLast { + // androidTest currently creates one .xml file per emulator with aggregate results in this dir + val folder = resultsDir.get().asFile + val listOfFiles = folder.listFiles { _, f -> Regex(".*.xml").matches(f) } + // No results at all is the same failure this guard exists to catch, and it is how the guard + // itself goes unnoticed if the results layout moves again + if (listOfFiles.isNullOrEmpty()) { + throw GradleException( + "No androidTest result files in " + folder + " - the connected test run reported success without producing results.", + ) + } + for (file in listOfFiles) { + // The root element carries the aggregate count for one device, across any number of + // nested per-class elements + val declaredTests = + groovy.xml + .XmlSlurper() + .parse(file) + .getProperty("@tests") + .toString() + if (declaredTests.trim().toIntOrNull() == null) { + throw GradleException("Unable to determine count of tests executed for " + file.name + ". Results format changed?") + } + if (declaredTests.trim().toInt() == 0) { + throw GradleException( + "androidTest executed 0 tests for " + file.name + " - Probably a bug with the emulator. Try another image.", + ) + } + } + } +} +afterEvaluate { + val connectedTest = + tasks + .named( + "connectedPlay${androidTestVariantName}AndroidTest", + ).get() + // Keep the preflight device set in lockstep with AGP's connected-test selection. The nested + // properties include task --serial and connectedCheck --serial after Gradle parses command-line + // task options; AGP applies the same precedence in getTargetSerials(). + // + // Reading these properties does not cause AGP's --serial crash. That crash is inside AGP 9.4 + // DeviceProviderInstrumentTestTask.getFilteredDevices(), which calls List.remove on the + // ImmutableList returned by ListProperty.get(). Reproduced without this wiring; no public + // tracker ID found as of 2026-09-22. Use ANDROID_SERIAL until AGP copies the list first. + tasks.named("assertDevicesUnlocked").configure { + directSerials.set(connectedTest.testRunnerFactory.deviceSerialValues) + connectedCheckSerials.set(connectedTest.testRunnerFactory.connectedCheckDeviceSerials) + } + connectedTest.dependsOn("assertDevicesUnlocked") + connectedTest.finalizedBy("assertNonzeroAndroidTests") + + // AGP bug: when testFixtures is enabled on an application module, the androidTest R8 task's + // TESTED_CODE scope only includes the app's dependency artifacts, not the app's own compiled + // classes. This causes R8 to report "Missing class" errors for all app classes referenced from + // androidTest code. Work around by adding the app classes as referenced (library) input. + if (testReleaseBuild) { + tasks.named("minifyPlayReleaseAndroidTestWithR8").configure { + referencedClasses.from( + files("build/intermediates/compile_app_classes_jar/playRelease/bundlePlayReleaseClassesToCompileJar/classes.jar"), + ) + } + } +} + +apply(from = "./robolectricDownloader.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 + } +} + +configurations.configureEach { + resolutionStrategy { + // Timber has this as a dependency but they are not up to date. We want to force our version. + force(libs.jetbrains.annotations) + } +} + +dependencies { + api(project(":api")) + implementation(libs.androidx.work.runtime) + lintChecks(project(":lint-rules")) + coreLibraryDesugaring(libs.desugar.jdk.libs.nio) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.auto.service.annotations) + annotationProcessor(libs.auto.service) + + // modules + implementation(project(":anki-common")) + implementation(project(":common")) + implementation(project(":common:android")) + implementation(project(":compat")) + implementation(project(":libanki")) + implementation(project(":vbpd")) + "baselineProfile"(project(":baselineprofile")) + implementation(project(":widgets")) + + implementation(libs.google.analytics.kt) + + implementation(libs.androidx.activity) + // TODO: hoist the Compose enablement (kotlin.compose plugin, buildFeatures.compose, + // and the dependencies below) into a convention plugin under `buildSrc/` once the + // Kotlin-DSL migration of this build script lands + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.annotation) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.browser) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.remoteviews) + implementation(libs.androidx.draganddrop) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.profileinstaller) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.sqlite.framework) + implementation(libs.androidx.swiperefreshlayout) + implementation(libs.androidx.viewpager2) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.webkit) + implementation(libs.google.material) + implementation(libs.android.image.cropper) + implementation(libs.nanohttpd) + implementation(libs.kotlinx.serialization.json) + implementation(libs.seismic) + implementation(libs.process.phoenix) + + debugImplementation(libs.androidx.fragment.testing.manifest) + + // Backend libraries + + implementation(libs.protobuf.kotlin.lite) // This is required when loading from a file + + // TODO: migrate to `addAnkiBackendDependencies(...)` when this is a gradle.kts file + val localProperties = Properties() + if (project.rootProject.file("local.properties").exists()) { + localProperties.load(project.rootProject.file("local.properties").inputStream()) + } + if (localProperties["local_backend"] == "true") { + implementation(files(rootProject.file("../Anki-Android-Backend/rsdroid/build/outputs/aar/rsdroid-release.aar"))) + testImplementation(files(rootProject.file("../Anki-Android-Backend/rsdroid-testing/build/libs/rsdroid-testing.jar"))) + } else { + implementation(libs.ankiBackend.backend) + testImplementation(libs.ankiBackend.testing) + } + + // May need a resolution strategy for support libs to our versions + implementation(libs.acra.limiter) + implementation(libs.acra.toast) + implementation(libs.acra.dialog) + implementation(libs.acra.http) + + implementation(libs.commons.compress) + implementation(libs.commons.collections4) // SetUniqueList + implementation(libs.commons.io) // FileUtils.contentEquals + implementation(libs.okhttp) + implementation(libs.slf4j.timber) + implementation(libs.jakewharton.timber) + implementation(libs.jsoup) + implementation(libs.java.semver) // For AnkiDroid JS API Versioning + implementation(libs.drakeet.drawer) + implementation(libs.skydoves.colorpickerview) + implementation(libs.kotlin.reflect) + implementation(libs.kotlin.test) + implementation(libs.search.preference) + + // Cannot use debugImplementation since classes need to be imported in AnkiDroidApp + // and there's no no-op version for release build. Usage has been disabled for release + // build via AnkiDroidApp. + implementation(libs.leakcanary.android) + + testImplementation(testFixtures(project(":libanki"))) + testImplementation(testFixtures(project(":common"))) + testImplementation(testFixtures(project(":common:android"))) + testImplementation(testFixtures(project(":compat"))) + testImplementation(testFixtures(project(":anki-common"))) + androidTestImplementation(testFixtures(project(":anki-common"))) + + // A path for a testing library which provide Parameterized Test + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.junit.vintage.engine) + testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.hamcrest) + testImplementation(libs.test.parameter.injector) + // robolectricDownloader.gradle *may* need a new SDK jar entry if they release one or if we change targetSdk. Instructions in that gradle file. + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + // Needed for createComposeRule(), but not for createAndroidComposeRule() + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.junit) + testImplementation(libs.kotlin.reflect) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlin.test.junit5) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.androidx.fragment.testing) + // in a JvmTest we need org.json.JSONObject to not be mocked + testImplementation(libs.json) + testImplementation(libs.ivanshafran.shared.preferences.mock) + testImplementation(libs.androidx.test.runner) + testImplementation(libs.androidx.test.rules) + testImplementation(libs.androidx.espresso.core) + testImplementation(libs.androidx.espresso.contrib) { + exclude(module = "protobuf-lite") + } + testImplementation(libs.androidx.work.testing) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.rule) + // for testing flows + testImplementation(libs.cashapp.turbine) + + androidTestImplementation(testFixtures(project(":anki-common"))) + + // May need a resolution strategy for support libs to our versions + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.contrib) { + exclude(module = "protobuf-lite") + } + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.kotlin.test) + androidTestImplementation(libs.kotlin.test.junit) + androidTestImplementation(libs.androidx.fragment.testing) + + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.dash) + implementation(libs.androidx.media3.ui) + + // ---- testFixtures setup ---- + testFixturesImplementation(libs.kotlin.stdlib) + testFixturesImplementation(libs.jakewharton.timber) + testFixturesImplementation(libs.hamcrest) + testFixturesImplementation(libs.androidx.test.junit) + // Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause + // an "unresolved" warning, which is treated as an error due to allWarningsAsErrors + testFixturesImplementation(libs.kotlinx.coroutines.core) + // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the + // module including testFixtures, which has no @Composable code and refuses to + // run unless the Compose Runtime is on the classpath. compileOnly satisfies the + // plugin's version check without shipping the runtime in the testFixtures output. + testFixturesCompileOnly(platform(libs.androidx.compose.bom)) + testFixturesCompileOnly(libs.androidx.compose.runtime) +} diff --git a/AnkiDroid/proguard-rules.pro b/AnkiDroid/proguard-rules.pro index c65148db65a3..8631f680f7bf 100644 --- a/AnkiDroid/proguard-rules.pro +++ b/AnkiDroid/proguard-rules.pro @@ -34,7 +34,6 @@ -keep class androidx.core.app.ActivityCompat$* { *; } -keep class androidx.concurrent.futures.** { *; } -keep class androidx.appcompat.view.menu.MenuItemImpl { *; } # .utils.ext.MenuItemImpl --keep class com.ichi2.anki.settings.PrefsRepository { *; } # PrefsRepository.notificationsPermissionRequested # used via exception.toString() -keepnames class * extends java.lang.Throwable diff --git a/AnkiDroid/src/androidTest/AndroidManifest.xml b/AnkiDroid/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000000..3c8a67e5a948 --- /dev/null +++ b/AnkiDroid/src/androidTest/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/TypeAnswerWebViewTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/TypeAnswerWebViewTest.kt new file mode 100644 index 000000000000..0467d413cb76 --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/TypeAnswerWebViewTest.kt @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.cardviewer + +import android.text.InputType +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.fragment.app.Fragment +import androidx.test.core.app.ActivityScenario +import com.ichi2.anki.SingleFragmentActivity +import com.ichi2.anki.tests.InstrumentedTest +import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission +import com.ichi2.anki.testutil.TestInputMethodRule +import com.ichi2.anki.testutil.awaitJavascript +import com.ichi2.anki.testutil.ensureWebViewIsSupported +import com.ichi2.anki.testutil.evaluate +import com.ichi2.anki.testutil.focusInput +import com.ichi2.anki.testutil.grantPermissions +import com.ichi2.anki.testutil.notificationPermission +import org.junit.Rule +import org.junit.Test + +/** Exercises Chromium, the bridge, and the input connection Android delivers to a real IME. */ +class TypeAnswerWebViewTest : InstrumentedTest() { + @get:Rule + val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission) + + @get:Rule + val keyboard = TestInputMethodRule() + + @Test + fun suggestionsAndSelectionFollowTheFocusedField() = + withAnswerPage { + focusInput("ordinary") + val baseline = keyboard.awaitEditor().inputType + keyboard.perform("commit", "ordinary text") + awaitJavascript("ordinary.value === 'ordinary text'") + evaluate("document.getElementById('ordinary').setSelectionRange(2, 5)") + + focusInput("answer") + keyboard.awaitEditor(InputType.TYPE_NULL) + keyboard.perform("commit", "été") + awaitJavascript("answer.value === 'été'") + evaluate("document.getElementById('answer').setSelectionRange(1, 2)") + + focusInput("ordinary") + keyboard.awaitEditor(baseline) + awaitJavascript("ordinary.value === 'ordinary text' && ordinary.selectionStart === 2 && ordinary.selectionEnd === 5") + + focusInput("answer") + keyboard.awaitEditor(InputType.TYPE_NULL) + awaitJavascript("answer.value === 'été' && answer.selectionStart === 1 && answer.selectionEnd === 2") + } + + @Test + fun markedFieldsKeepNoSuggestWhenCardScriptsStopFocusPropagation() = + withAnswerPage { + focusInput("answer") + keyboard.awaitEditor(InputType.TYPE_NULL) + evaluate( + """ + window.keyboardStates = []; + const nativeKeyboard = AnkiDroidKeyboard; + AnkiDroidKeyboard = { setNoSuggest(enabled) { + keyboardStates.push(enabled); + nativeKeyboard.setNoSuggest(enabled); + }}; + """.trimIndent(), + ) + focusInput("otherAnswer") + awaitJavascript("keyboardStates.length > 0 && keyboardStates.every(enabled => enabled === true)") + keyboard.awaitEditor(InputType.TYPE_NULL) + keyboard.perform("commit", "second") + awaitJavascript("otherAnswer.value === 'second'") + focusInput("ordinary") + keyboard.awaitEditor() + } + + @Test + fun noSuggestAcceptsCommittedUnicodeAndCompositionFromIme() = + withAnswerPage { + focusInput("answer") + keyboard.awaitEditor(InputType.TYPE_NULL) + keyboard.perform("commit", "été ") + keyboard.perform("compose", "に") + keyboard.perform("compose", "日本") + keyboard.perform("finish") + awaitJavascript("answer.value === 'été 日本'") + } + + @Test + fun noSuggestAcceptsCharactersSentByIme() = + withAnswerPage { + focusInput("answer") + keyboard.awaitEditor(InputType.TYPE_NULL) + keyboard.perform("chars", "été") + awaitJavascript("answer.value === 'été'") + } + + @Test + fun noSuggestRequestsDoneAndDeliversEnter() = + withAnswerPage { + focusInput("answer") + keyboard.awaitEditor(InputType.TYPE_NULL, imeAction = EditorInfo.IME_ACTION_DONE) + keyboard.perform("done") + awaitJavascript("document.body.dataset.key === 'Enter'") + } + + @Test + fun restoresSuggestionsWhenFocusingAReplacementInput() = + withAnswerPage { + focusInput("ordinary") + val baseline = keyboard.awaitEditor().inputType + focusInput("answer") + keyboard.awaitEditor(InputType.TYPE_NULL) + // Removing the focused node does not necessarily fire blur. + evaluate("document.body.innerHTML = ''") + focusInput("next") + keyboard.awaitEditor(baseline) + } + + private fun withAnswerPage(block: TypeAnswerWebView.() -> Unit) { + ensureWebViewIsSupported() + val intent = SingleFragmentActivity.getIntent(testContext, Fragment::class) + ActivityScenario.launch(intent).use { scenario -> + lateinit var webView: TypeAnswerWebView + scenario.onActivity { webView = TypeAnswerWebView(it) } + try { + scenario.onActivity { activity -> + activity.setContentView(webView) + webView.settings.javaScriptEnabled = true + webView.settings.allowFileAccess = true + webView.loadDataWithBaseURL( + "file:///android_asset/", + """ + +
+ + + +
+ + """.trimIndent(), + "text/html", + null, + null, + ) + } + webView.awaitJavascript("document.readyState === 'complete' && document.getElementById('answer') !== null") + webView.block() + } finally { + scenario.onActivity { + (webView.parent as? ViewGroup)?.removeView(webView) + webView.destroy() + } + } + } + } +} diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt index 9705445506b0..c3672a7eb83b 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt @@ -54,6 +54,8 @@ import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.greaterThan import org.hamcrest.Matchers.greaterThanOrEqualTo import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.notNullValue import org.json.JSONObject.NULL import org.junit.After import org.junit.Assert.assertEquals @@ -367,6 +369,88 @@ class ContentProviderTest : InstrumentedTest() { } } + @Test + fun testSearchCards_fillWindowCopiesAllRows() { + val note = addTempClozeNote("{{c1::A}} {{c2::B}}") + val cursor = + contentResolver.query(FlashCardsContract.Card.CONTENT_URI, null, "nid:${note.id}", null, null) + assertThat(cursor, notNullValue()) + cursor!!.use { + val initialPosition = it.position + CursorWindow("test").use { window -> + cursorFillWindow(it, 0, window) + assertThat(it.position, equalTo(initialPosition)) + assertThat(window.numRows, equalTo(2)) + val noteIdColumn = it.getColumnIndexOrThrow(FlashCardsContract.Card.NOTE_ID) + assertThat(window.getLong(0, noteIdColumn), equalTo(note.id)) + assertThat(window.getLong(1, noteIdColumn), equalTo(note.id)) + } + } + } + + @Test + fun testSearchCards_randomAccessIsStable() { + val note = addTempClozeNote("{{c1::A}} {{c2::B}}") + val cursor = + contentResolver.query(FlashCardsContract.Card.CONTENT_URI, null, "nid:${note.id}", null, null) + assertThat(cursor, notNullValue()) + cursor!!.use { + fun rowAt(position: Int): List { + assertThat(it.moveToPosition(position), equalTo(true)) + return (0 until it.columnCount).map { column -> it.getString(column) } + } + + val forwards = (0 until it.count).map { position -> rowAt(position) } + assertThat(forwards, hasSize(2)) + for (position in it.count - 1 downTo 0) { + assertThat(rowAt(position), equalTo(forwards[position])) + } + assertThat(rowAt(0), equalTo(forwards[0])) + } + } + + @Test + fun testSearchCards_deletedCardRetainsIdWithNullFields() { + val note = addTempClozeNote("{{c1::A}} {{c2::B}}") + val cardIds = col.findCards("nid:${note.id}") + val cursor = + contentResolver.query(FlashCardsContract.Card.CONTENT_URI, null, "nid:${note.id}", null, null) + assertThat(cursor, notNullValue()) + cursor!!.use { + col.removeNotes(noteIds = listOf(note.id)) + assertThat(it.count, equalTo(cardIds.size)) + val idColumn = it.getColumnIndexOrThrow(FlashCardsContract.Card._ID) + val returnedIds = mutableListOf() + while (it.moveToNext()) { + returnedIds.add(it.getLong(idColumn)) + for (column in 0 until it.columnCount) { + if (column == idColumn) continue + assertThat("${it.getColumnName(column)} should be null", it.isNull(column), equalTo(true)) + } + } + assertThat(returnedIds.toSet(), equalTo(cardIds.toSet())) + } + } + + @Test + fun testSearchCards_unknownColumnThrowsFromQuery() { + // Validate the projection even when the search has no results. + for (query in listOf("", "cid:0")) { + val exception = + assertThrows { + contentResolver + .query( + FlashCardsContract.Card.CONTENT_URI, + arrayOf(FlashCardsContract.Card._ID, "not_a_column"), + query, + null, + null, + )?.close() + } + assertThat(exception.message, containsString("not_a_column")) + } + } + @Test fun testQueryCardById() { val card = getFirstCardFromScheduler(col) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethod.java b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethod.java new file mode 100644 index 000000000000..30cf152dd24a --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethod.java @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.testutil; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.inputmethodservice.InputMethodService; +import android.net.Uri; +import android.os.Binder; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Process; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; + +/** + * A real IME: metadata and input travel through Android's active connection. + * Uses only framework classes because the test APK's separate process cannot load + * dependencies (including Kotlin) packaged only in the application under test. + * This checks the input protocol; keyboard UI compatibility also needs a device + * check (for example, long-press e for an accent, then submit with Done). + */ +public class TestInputMethod extends InputMethodService { + private static TestInputMethod instance; + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + } + + @Override + public void onDestroy() { + instance = null; + super.onDestroy(); + } + + private Bundle command(String method, String text) { + Bundle result = new Bundle(); + EditorInfo info = getCurrentInputEditorInfo(); + if (!getCurrentInputStarted() || info == null) return result; + result.putParcelable("editor", info); + InputConnection connection = getCurrentInputConnection(); + if (connection == null) return result; + boolean accepted = true; + switch (method) { + case "editor": + break; + case "commit": + accepted = connection.commitText(text, 1); + break; + case "compose": + accepted = connection.setComposingText(text, 1); + break; + case "finish": + accepted = connection.finishComposingText(); + break; + case "done": + accepted = connection.performEditorAction(EditorInfo.IME_ACTION_DONE); + break; + case "chars": + for (char c : text.toCharArray()) sendKeyChar(c); + break; + default: + throw new IllegalArgumentException(method); + } + result.putBoolean("accepted", accepted); + return result; + } + + public static class Control extends ContentProvider { + @Override + public Bundle call(String method, String arg, Bundle extras) { + // Check on the Binder thread, before posting loses the caller's identity. + if (getContext().getPackageManager().checkSignatures(Binder.getCallingUid(), Process.myUid()) + != PackageManager.SIGNATURE_MATCH) { + throw new SecurityException("Test IME control requires a matching signature"); + } + FutureTask task = new FutureTask<>(() -> + instance == null ? new Bundle() : instance.command(method, arg)); + new Handler(Looper.getMainLooper()).post(task); + try { + return task.get(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException("Test IME did not respond", e); + } + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] args, String order) { + throw new UnsupportedOperationException(); + } + + @Override + public String getType(Uri uri) { + throw new UnsupportedOperationException(); + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException(); + } + + @Override + public int delete(Uri uri, String selection, String[] args) { + throw new UnsupportedOperationException(); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] args) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodControlTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodControlTest.kt new file mode 100644 index 000000000000..1936937618ee --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodControlTest.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.testutil + +import android.os.ParcelFileDescriptor +import androidx.core.net.toUri +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Test +import kotlin.test.assertContains +import kotlin.test.assertNotNull + +class TestInputMethodControlTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val uri = "content://${instrumentation.context.packageName}.ime".toUri() + + @Test + fun instrumentationCanAccessProvider() { + assertNotNull(instrumentation.context.contentResolver.call(uri, "editor", null, null)) + } + + @Test + @SdkSuppress(minSdkVersion = 34) // executeShellCommandRwe captures the command's error output. + fun callerWithDifferentSignatureIsRejected() { + // The shell has a different signing certificate and makes a real cross-process call. + val pipes = instrumentation.uiAutomation.executeShellCommandRwe("content call --uri $uri --method editor") + try { + pipes[1].close() + val error = ParcelFileDescriptor.AutoCloseInputStream(pipes[2]).bufferedReader().use { it.readText() } + assertContains(error, "java.lang.SecurityException: Test IME control requires a matching signature") + } finally { + pipes.forEach { it.close() } + } + } +} diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodRule.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodRule.kt new file mode 100644 index 000000000000..28b8cce04bff --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodRule.kt @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.testutil + +import android.annotation.SuppressLint +import android.os.Bundle +import android.text.InputType +import android.view.inputmethod.EditorInfo +import androidx.core.net.toUri +import androidx.core.os.BundleCompat +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.MultipleFailureException +import org.junit.runners.model.Statement +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Temporarily selects the test APK's IME and restores the previous keyboard even on failure. */ +class TestInputMethodRule( + private val executeShellCommand: (String) -> String = ::executeImeCommand, +) : TestRule { + private val instrumentation get() = InstrumentationRegistry.getInstrumentation() + private val testPackage get() = instrumentation.context.packageName + private val uri get() = "content://$testPackage.ime".toUri() + + override fun apply( + base: Statement, + description: Description, + ): Statement = + object : Statement() { + override fun evaluate() { + val previous = executeShellCommand("settings get secure default_input_method").trim() + val ime = "$testPackage/${TestInputMethod::class.java.name}" + val wasEnabled = executeShellCommand("ime list -s").lineSequence().any { it.trim() == ime } + val failures = mutableListOf() + try { + executeShellCommand("ime enable $ime") + executeShellCommand("ime set $ime") + assertEquals(ime, executeShellCommand("settings get secure default_input_method").trim()) + base.evaluate() + } catch (failure: Throwable) { + failures.add(failure) + } + // Attempt each cleanup independently, preserving the original test failure. + try { + if (previous.isNotEmpty() && previous != "null") { + val restoreOutput = executeShellCommand("ime set $previous") + assertEquals( + previous, + executeShellCommand("settings get secure default_input_method").trim(), + "Failed to restore keyboard $previous: $restoreOutput", + ) + } + } catch (failure: Throwable) { + failures.add(failure) + } + try { + if (!wasEnabled) { + val disableOutput = executeShellCommand("ime disable $ime") + assertFalse( + executeShellCommand("ime list -s").lineSequence().any { it.trim() == ime }, + "Failed to disable test keyboard $ime: $disableOutput", + ) + } + } catch (failure: Throwable) { + failures.add(failure) + } + MultipleFailureException.assertEmpty(failures) + } + } + + private fun snapshot(): Bundle = assertNotNull(instrumentation.context.contentResolver.call(uri, "editor", null, null)) + + /** Chromium and the keyboard bridge update asynchronously; match all requested properties in one snapshot. */ + fun awaitEditor( + inputType: Int? = null, + imeAction: Int? = null, + ): EditorInfo { + var info: EditorInfo? = null + waitUntil(message = { + val actualAction = info?.imeOptions?.and(EditorInfo.IME_MASK_ACTION) + "IME expected inputType=$inputType, action=$imeAction; " + + "received inputType=${info?.inputType}, action=$actualAction, package=${info?.packageName}" + }) { + info = BundleCompat.getParcelable(snapshot(), "editor", EditorInfo::class.java) + val editor = info ?: return@waitUntil false + val matchesInputType = + if (inputType == null) { + editor.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_CLASS_TEXT && + editor.inputType and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT + } else { + editor.inputType == inputType + } + editor.packageName == instrumentation.targetContext.packageName && matchesInputType && + (imeAction == null || editor.imeOptions and EditorInfo.IME_MASK_ACTION == imeAction) + } + return assertNotNull(info) + } + + fun perform( + action: String, + text: String? = null, + ) { + val result = instrumentation.context.contentResolver.call(uri, action, text, null) + assertTrue(result?.getBoolean("accepted") == true, "IME rejected $action") + } +} + +// Commands need no shell syntax or stdin; the rule verifies the resulting keyboard state. +@SuppressLint("DiscouragedApi") +private fun executeImeCommand(command: String): String = + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).executeShellCommand(command) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodRuleTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodRuleTest.kt new file mode 100644 index 000000000000..afbfff05414a --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodRuleTest.kt @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.testutil + +import org.junit.Test +import org.junit.runner.Description +import org.junit.runners.model.MultipleFailureException +import org.junit.runners.model.Statement +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame + +class TestInputMethodRuleTest { + @Test + fun testFailureSurvivesSuccessfulCleanup() { + val shell = KeyboardShell() + val original = AssertionError("test failed") + + val failure = assertFailsWith { shell.evaluate { throw original } } + + assertSame(original, failure) + assertEquals(shell.previous, shell.selected) + assertNull(shell.enabledIme) + } + + @Test + fun testAndBothCleanupFailuresAreReported() { + val original = AssertionError("test failed") + val restoreFailure = IllegalStateException("restore failed") + val disableFailure = IllegalStateException("disable failed") + val shell = KeyboardShell(restoreFailure, disableFailure) + + val failure = assertFailsWith { shell.evaluate { throw original } } + + assertEquals(listOf(original, restoreFailure, disableFailure), failure.failures) + } + + /** Models keyboard selection without changing the emulator's keyboard. */ + private class KeyboardShell( + private val restoreFailure: Throwable? = null, + private val disableFailure: Throwable? = null, + ) { + val previous = "previous.ime/.Keyboard" + var selected = previous + var enabledIme: String? = null + + fun evaluate(block: () -> Unit) { + val statement = + object : Statement() { + override fun evaluate() = block() + } + TestInputMethodRule(::execute).apply(statement, Description.EMPTY).evaluate() + } + + private fun execute(command: String): String { + when { + command == "settings get secure default_input_method" -> return selected + command == "ime list -s" -> return listOfNotNull(previous, enabledIme).joinToString("\n") + command.startsWith("ime enable ") -> enabledIme = command.removePrefix("ime enable ") + command.startsWith("ime set ") -> { + val ime = command.removePrefix("ime set ") + if (ime == previous) restoreFailure?.let { throw it } + selected = ime + } + command.startsWith("ime disable ") -> { + disableFailure?.let { throw it } + enabledIme = null + } + else -> error("Unexpected shell command: $command") + } + return "" + } + } +} diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/WebViewInput.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/WebViewInput.kt new file mode 100644 index 000000000000..fc28c8b4021d --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/WebViewInput.kt @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.testutil + +import android.webkit.WebView +import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +fun WebView.evaluate(script: String): String { + val result = CompletableFuture() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + evaluateJavascript(script) { result.complete(it) } + } + return result.get(5, TimeUnit.SECONDS) +} + +fun WebView.awaitJavascript(condition: String) { + waitUntil(message = { "JavaScript condition did not become true: $condition" }) { + evaluate(condition) == "true" + } +} + +fun WebView.focusInput(id: String) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + requestFocus() + // loadUrl also permits keyboard activation before the WebView's first touch. + loadUrl("javascript:document.getElementById('$id').focus();") + } + awaitJavascript("document.activeElement?.id === '$id'") +} diff --git a/AnkiDroid/src/androidTest/res/xml/test_input_method.xml b/AnkiDroid/src/androidTest/res/xml/test_input_method.xml new file mode 100644 index 000000000000..df3e07cab78d --- /dev/null +++ b/AnkiDroid/src/androidTest/res/xml/test_input_method.xml @@ -0,0 +1,3 @@ + + + diff --git a/AnkiDroid/src/main/assets/scripts/type-answer.js b/AnkiDroid/src/main/assets/scripts/type-answer.js new file mode 100644 index 000000000000..a4b8ace24ead --- /dev/null +++ b/AnkiDroid/src/main/assets/scripts/type-answer.js @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Keep nosuggest scoped to the marked input, including when a card has other editable fields. +// A new document must not inherit the previous card's keyboard state. +AnkiDroidKeyboard.setNoSuggest(false); + +// Capture before card scripts can stop propagation. On focusout, use the destination +// so moving between marked fields does not temporarily restore suggestions. +document.addEventListener( + "focusin", + event => { + AnkiDroidKeyboard.setNoSuggest(event.target.dataset.ankidroidNosuggest === "true"); + }, + true, +); +document.addEventListener( + "focusout", + event => { + AnkiDroidKeyboard.setNoSuggest(event.relatedTarget?.dataset?.ankidroidNosuggest === "true"); + }, + true, +); diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractIntentHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractIntentHandler.kt index a609ffcb8632..c1181623a8f8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractIntentHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractIntentHandler.kt @@ -6,6 +6,7 @@ package com.ichi2.anki import android.app.Activity import android.os.Bundle import com.ichi2.anki.common.android.themes.disableXiaomiForceDarkMode +import com.ichi2.anki.workarounds.AppLoadedFromBackupWorkaround.showedActivityFailedScreen import com.ichi2.themes.Themes /** @@ -13,6 +14,13 @@ import com.ichi2.themes.Themes * By centralizing common setup tasks here, it promotes code reuse. */ abstract class AbstractIntentHandler : Activity() { + /** Stop intent dispatch when Android's backup mode has skipped application and provider initialization. */ + protected fun showedActivityFailedScreen(savedInstanceState: Bundle?) = + showedActivityFailedScreen( + savedInstanceState = savedInstanceState, + activitySuperOnCreate = { state -> super.onCreate(state) }, + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Themes.setTheme(this, savedInstanceState) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 5a6022841bc6..b392ed7cf75a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -44,6 +44,7 @@ import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.compat.CompatHelper import com.ichi2.anki.contextmenu.AnkiCardContextMenu import com.ichi2.anki.contextmenu.CardBrowserContextMenu +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.logging.FragmentLifecycleLogger @@ -54,6 +55,7 @@ import com.ichi2.anki.logging.logActivityCreation import com.ichi2.anki.model.FieldFilters.NoSuggestFilter import com.ichi2.anki.multimedia.MultimediaArgsStorage import com.ichi2.anki.multiprofile.ProfileManager +import com.ichi2.anki.multiprofile.isPhoenixProcess import com.ichi2.anki.navigation.initializeNavigator import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.preferences.SharedPreferencesProvider @@ -170,6 +172,10 @@ open class AnkiDroidApp : */ @KotlinCleanup("analytics can be moved to attachBaseContext()") override fun onCreate() { + if (isPhoenixProcess()) { + super.onCreate() + return + } initAnkiBackend(debugTraceSqlCalls = false) super.onCreate() if (!setupAnkiDroidApp()) { @@ -183,6 +189,9 @@ open class AnkiDroidApp : initializeWidgetRepository() WidgetNotificationScheduler.register { scheduleNotification() } Animations.setPreferencesProvider { context -> PrefsRepository(context) } + CollectionLockedException.messageProvider = { + getString(CommonString.database_locked_summary_new, getString(CommonString.col_path)) + } val logType = LogType.value when (logType) { LogType.DEBUG -> Timber.plant(DebugTree()) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt index 800b64a6351c..2cb302ab2314 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt @@ -21,6 +21,8 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.libanki.Collection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.BackendException @@ -57,7 +59,22 @@ fun Activity.importColpkg(colpkgPath: String) where Activity : AnkiAc } } -private suspend fun createBackup(force: Boolean): Boolean { +/** + * Held for the whole of a background backup, including the stage the backend + * finishes after it has released the collection. + * + * Waiting on the backend directly does not work from elsewhere: it hands its + * running backup to whichever caller asks first, and [createBackup] asks as soon + * as the backup starts, so a second caller returns without waiting. + */ +val backupLock = Mutex() + +private suspend fun createBackup(force: Boolean) = + backupLock.withLock { + createBackupHoldingLock(force) + } + +private suspend fun createBackupHoldingLock(force: Boolean): Boolean { val created = withCol { // this two-step approach releases the backend lock after the initial copy diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 058b76403b1d..ea4044e3b72d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -31,11 +31,14 @@ import com.ichi2.anki.common.annotations.UseContextParameter import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination +import com.ichi2.anki.common.destinations.PreferencesDestination import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.pages.toIntent +import com.ichi2.anki.preferences.toIntent import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.startup.redirectToMainEntryPoint import com.ichi2.anki.ui.internationalization.sentenceCase @@ -163,7 +166,9 @@ suspend fun FragmentActivity.runCatching( Timber.w(exc, errorMessage) if (!isFinishing) redirectToMainEntryPoint() } - is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException -> { + is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException, + is CollectionLockedException, + -> { // these exceptions do not generate worthwhile crash reports Timber.i("Showing error dialog but not sending a crash report.") showError(exc.localizedMessage!!, exc.toCrashReportData(this, reportException = false)) @@ -686,6 +691,20 @@ data class CrashReportData( } } + /** + * Opens settings at the Advanced screen, where changing the 'AnkiDroid directory' + * resolves a [CollectionLockedException] (#21051) + */ + data object OpenAdvancedSettings : HelpAction() { + override fun buttonText(context: Context): CharSequence = context.getString(CommonString.settings) + + override suspend fun execute(context: Context): Boolean { + Timber.i("Opening 'Advanced settings'") + context.startActivity(PreferencesDestination.Advanced.toIntent(context)) + return true + } + } + /** Opens 'Check Database' */ data object OpenCheckDatabase : HelpAction() { override fun buttonText(context: Context): CharSequence = with(context) { TR.sentenceCase.checkDatabase } @@ -714,6 +733,7 @@ data class CrashReportData( } if (link != null) return AnkiBackendLink(link) + if (e is CollectionLockedException) return OpenAdvancedSettings if (e.isInvalidFsrsParametersException()) return OpenDeckOptions if (e.isDeckNotFoundInLimitsMapException()) return OpenCheckDatabase diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index 73df41266d34..5cfd63d28f17 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -24,6 +24,7 @@ import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.storage.isLegacyStorage import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.servicelayer.PreferenceUpgradeService import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate @@ -69,7 +70,7 @@ object InitialActivity { try { CollectionManager.getColUnsafe() return null - } catch (e: BackendException.BackendDbException.BackendDbLockedException) { + } catch (e: CollectionLockedException) { Timber.w(e) StartupFailure.DatabaseLocked } catch (e: BackendException.BackendDbException.BackendDbFileTooNewException) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt index 0cf54fec60d7..ca424c2f39af 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt @@ -13,6 +13,7 @@ import androidx.annotation.VisibleForTesting import androidx.core.app.TaskStackBuilder import androidx.core.content.FileProvider import androidx.work.WorkManager +import com.ichi2.anki.common.analytics.reportPotentiallyDeadCode import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.destinations.BrowserDestination @@ -61,6 +62,9 @@ import kotlin.math.min class IntentHandler : AbstractIntentHandler() { override fun onCreate(savedInstanceState: Bundle?) { // Note: This is our entry point from the launcher with intent: android.intent.action.MAIN + if (showedActivityFailedScreen(savedInstanceState)) { + return + } super.onCreate(savedInstanceState) val intent = intent Timber.v(intent.toString()) @@ -73,9 +77,11 @@ class IntentHandler : AbstractIntentHandler() { val launchType = getLaunchType(intent) // TODO block the UI with some kind of ProgressDialog instead of cancelling the sync work if (requiresCollectionAccess(launchType)) { - // # 18899 if (WorkManager.isInitialized()) { SyncWorker.cancel(this) + } else { + // #18899: suspected dead code now that backup recovery runs first. + reportPotentiallyDeadCode("IntentHandler.WorkManagerNotInitialized") } } when (launchType) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler2.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler2.kt index 75ab625e6ce6..058a9223d6c4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler2.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler2.kt @@ -19,6 +19,9 @@ import timber.log.Timber */ class IntentHandler2 : AbstractIntentHandler() { override fun onCreate(savedInstanceState: Bundle?) { + if (showedActivityFailedScreen(savedInstanceState)) { + return + } super.onCreate(savedInstanceState) Timber.v(intent.toString()) if (NoteEditorFragment.intentLaunchedWithImage(intent)) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 727b59e8085c..cd972aa7ed07 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -159,6 +159,7 @@ import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.ui.setupNoteTypeSpinner +import com.ichi2.anki.utils.HanUnificationDetector import com.ichi2.anki.utils.RunOnlyOnce import com.ichi2.anki.utils.bottomCornerSideClearance import com.ichi2.anki.utils.doOnApplyWindowInsets @@ -1296,6 +1297,8 @@ class NoteEditorFragment : for (f in editFields!!) { updateField(f) } + // Check for Han Unification issues (Stage 1: detection only) + checkForHanUnificationIssues() // Save tags to model editorNote!!.setTagsFromStr(getColUnsafe, tagsAsString(selectedTags!!)) val tags = JSONArray() @@ -1340,6 +1343,8 @@ class NoteEditorFragment : for (f in editFields!!) { modified = modified or updateField(f) } + // Check for Han Unification issues (Stage 1: detection only) + checkForHanUnificationIssues() // added tag? for (t in selectedTags!!) { modified = modified || !editorNote!!.hasTag(getColUnsafe, tag = t) @@ -1396,6 +1401,41 @@ class NoteEditorFragment : closeNoteEditor() } + /** + * Checks note fields for potential Han Unification rendering issues. + * + * This detection runs during note save/edit to catch issues early, before the user encounters + * them during review. + * + * @see HanUnificationDetector for detection logic + */ + private fun checkForHanUnificationIssues() { + if (editFields == null || editFields!!.isEmpty()) { + return + } + + val noteId = editorNote?.id ?: 0L + val noteIdentifier = if (noteId == 0L) "New note" else "Note ID: $noteId" + + editFields!!.forEachIndexed { index, field -> + val fieldText = field.fieldText ?: return@forEachIndexed + if (fieldText.isBlank()) { + return@forEachIndexed + } + + val hasIssue = HanUnificationDetector.detectIssue(fieldText) + if (!hasIssue) return@forEachIndexed + + val fieldName = + runCatching { editorNote?.notetype?.getField(index) } + .getOrNull() ?: "Field $index" + + Timber.w( + "Han Unification issue detected - $noteIdentifier, Field: '$fieldName' (index $index)", + ) + } + } + /** * Launches the [ChangeNoteTypeDialog] for the current note being edited. * Used in edit mode when the user taps the note type spinner. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt index 37bb1b300c5d..4da00ebc5964 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt @@ -21,6 +21,7 @@ import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.ShortcutGroupProvider import com.ichi2.anki.common.destinations.StudyOptionsDestination import com.ichi2.anki.common.destinations.navigate +import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyAction import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder @@ -88,8 +89,15 @@ open class SingleFragmentActivity : return } + val fragment = createFragment() + if (fragment == null) { + Timber.w("'%s' extra not provided, finishing", EXTRA_FRAGMENT_NAME) + showThemedToast(this, R.string.something_wrong, false) + finish() + return + } supportFragmentManager.commit { - replace(R.id.fragment_container, createFragment(), FRAGMENT_TAG) + replace(R.id.fragment_container, fragment, FRAGMENT_TAG) } setFragmentResultListener(CustomStudyAction.REQUEST_KEY) { _, bundle -> @@ -113,9 +121,8 @@ open class SingleFragmentActivity : } } - protected open fun createFragment(): Fragment { - val fragmentClassName = - requireNotNull(intent.getStringExtra(EXTRA_FRAGMENT_NAME)) { "'$EXTRA_FRAGMENT_NAME' extra should be provided" } + protected open fun createFragment(): Fragment? { + val fragmentClassName = intent.getStringExtra(EXTRA_FRAGMENT_NAME) ?: return null Timber.d("Creating fragment %s", fragmentClassName) return FragmentFactoryUtils.instantiate(this, fragmentClassName).apply { arguments = intent.getBundleExtra(EXTRA_FRAGMENT_ARGS) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt index 1a6dd2b8711a..3965a07e2e32 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt @@ -30,11 +30,14 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString +import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.DeckPicker import com.ichi2.anki.R import com.ichi2.anki.account.AccountActivity.Companion.START_FROM_DECKPICKER import com.ichi2.anki.dialogs.help.HelpDialog +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.getEndpoint +import com.ichi2.anki.showError import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.bottomCornerClearance @@ -244,7 +247,15 @@ class LoginFragment : Fragment(R.layout.fragment_my_account) { showLoginSuccessDialog() } is LoginState.Error -> { - showSnackbar(text = state.exception.message.toString()) + when (val exception = state.exception) { + // the locked-collection guidance is too long for a snackbar + is CollectionLockedException -> + requireContext().showError( + exception.localizedMessage!!, + exception.toCrashReportData(requireContext(), reportException = false), + ) + else -> showSnackbar(text = exception.message.toString()) + } } is LoginState.Idle -> { /* Not needed */ } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt index cb8a409c4220..08b1f03a2e84 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt @@ -29,6 +29,7 @@ import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.utils.android.darkenColor import com.ichi2.anki.common.utils.android.lightenColorAbsolute import com.ichi2.anki.common.utils.ext.replaceWith +import com.ichi2.anki.common.utils.ext.setBitFlag import com.ichi2.anki.databinding.ItemCardBrowserBinding import com.ichi2.anki.databinding.ViewBrowserColumnCellBinding import com.ichi2.themes.Themes @@ -198,12 +199,7 @@ class BrowserMultiColumnAdapter( } private fun TextView.setStrikeThrough(strikeThrough: Boolean) { - paintFlags = - if (strikeThrough) { - paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - } else { - paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } + paintFlags = paintFlags.setBitFlag(Paint.STRIKE_THRU_TEXT_FLAG, enabled = strikeThrough) } private fun TextView.setupTextSize() { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt new file mode 100644 index 000000000000..42327e1e81c5 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.cardviewer + +import android.content.Context +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputMethodManager +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.core.content.getSystemService +import com.ichi2.ui.applyNoSuggest + +/** Applies `nosuggest` to the focused HTML answer field's keyboard connection. */ +open class TypeAnswerWebView( + context: Context, +) : WebView(context) { + // All keyboard state is owned by the UI thread. + private var noSuggest = false + + private var destroyed = false + + init { + addJavascriptInterface(Keyboard(), "AnkiDroidKeyboard") + } + + private inner class Keyboard { + @JavascriptInterface + fun setNoSuggest(enabled: Boolean) { + // JavaScript interfaces run on WebView's bridge thread. + post { + if (destroyed || noSuggest == enabled) return@post + noSuggest = enabled + // Chromium may reuse an input connection between HTML text fields. + if (isAttachedToWindow) { + context.getSystemService()?.restartInput(this@TypeAnswerWebView) + } + } + } + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? = + super.onCreateInputConnection(outAttrs).applyNoSuggest(outAttrs, noSuggest) + + override fun destroy() { + destroyed = true + super.destroy() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt index f039a9c6866d..6138da019291 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt @@ -9,6 +9,8 @@ import android.graphics.Color import android.os.Build import android.os.Bundle import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -26,6 +28,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment @@ -45,6 +48,7 @@ import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment import com.ichi2.anki.common.annotations.NeedsTest +import com.ichi2.anki.compat.setTooltipTextCompat import com.ichi2.anki.databinding.DialogChangeNoteTypeBinding import com.ichi2.anki.databinding.DialogFieldsBinding import com.ichi2.anki.databinding.DialogTemplatesBinding @@ -178,6 +182,7 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note private fun setupNoteTypeSpinner(binding: DialogChangeNoteTypeBinding) { binding.CardEditorModelText.text = BidiFormatter.getInstance().unicodeWrap(viewModel.inputNoteType.name) + binding.CardEditorModelText.setTooltipTextCompat(viewModel.inputNoteType.name) binding.destNoteTypeSpinner.apply { adapter = createNoteTypeAdapter() @@ -225,7 +230,10 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note text = noteType.name setTextColor(if (noteType.isCloze) clozeColor else defaultViewTextColor) isSingleLine = false - ellipsize = null + // Leave room for the mappings even when the selected note type has a long name. + maxLines = resources.getInteger(R.integer.change_note_type_name_max_lines) + ellipsize = TextUtils.TruncateAt.END + setTooltipTextCompat(noteType.name) updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT } @@ -264,6 +272,25 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val tabLayout = binding.changeNoteTypeTabLayout createTabMediator(tabLayout, viewPager).attach() + // Measure the labels and icons before fixed tabs constrain their width. + val minimumTabWidth = + (0 until tabLayout.tabCount).maxOf { position -> + val tab = requireNotNull(tabLayout.getTabAt(position)) + val content = requireNotNull(tab.customView) + content.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + content.measuredWidth + tab.view.paddingLeft + tab.view.paddingRight + } + tabLayout.addOnLayoutChangeListener { _, left, _, right, _, _, _, _, _ -> + val availableWidth = right - left - tabLayout.paddingLeft - tabLayout.paddingRight + val mode = + if (minimumTabWidth * tabLayout.tabCount <= availableWidth) TabLayout.MODE_FIXED else TabLayout.MODE_AUTO + if (tabLayout.tabMode != mode) { + tabLayout.tabMode = mode + tabLayout.doOnNextLayout { + tabLayout.setScrollPosition(tabLayout.selectedTabPosition, 0f, true) + } + } + } // Explicitly set initial tab in ViewModel to match UI viewPager.setCurrentItem(0, false) @@ -423,16 +450,6 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val inputFieldNames = viewModel.inputNoteType.fieldsNames val outputFieldNames = viewModel.outputNoteType.fieldsNames - fun buildFieldLayout() = - LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = - LinearLayout.LayoutParams( - MATCH_PARENT, - WRAP_CONTENT, - ) - } - fun buildFieldSpinner(spinnerIndex: Int) = Spinner(requireContext()) .apply { @@ -475,20 +492,8 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note } } - fun buildFieldText(initialText: String) = - MaterialTextView(requireContext()).apply { - layoutParams = - LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) - text = initialText - textAlignment = View.TEXT_ALIGNMENT_CENTER - } - for (i in outputFieldNames.indices) { - val fieldLayout = - buildFieldLayout().apply { - addView(buildFieldSpinner(i)) - addView(buildFieldText(outputFieldNames[i])) - } - binding.fieldsContainer.addView(fieldLayout) + for ((i, name) in outputFieldNames.withIndex()) { + binding.fieldsContainer.addView(createMappingRow(buildFieldSpinner(i), name)) } } } @@ -623,16 +628,6 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val inputTemplateNames = viewModel.inputNoteType.templatesNames val outputTemplateNames = viewModel.outputNoteType.templatesNames - fun buildTemplateLayout() = - LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = - LinearLayout.LayoutParams( - MATCH_PARENT, - WRAP_CONTENT, - ) - } - fun buildTemplateSpinner(spinnerIndex: Int) = Spinner(requireContext()) .apply { @@ -668,30 +663,32 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note } } - fun buildTemplateText(templateName: String) = - MaterialTextView(requireContext()).apply { - layoutParams = - LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) - text = templateName - textAlignment = View.TEXT_ALIGNMENT_CENTER - } - for ((i, name) in outputTemplateNames.withIndex()) { - val templateLayout = - buildTemplateLayout().apply { - addView( - buildTemplateSpinner( - spinnerIndex = i, - ), - ) - addView(buildTemplateText(templateName = name)) - } - binding.templatesContainer.addView(templateLayout) + binding.templatesContainer.addView(createMappingRow(buildTemplateSpinner(i), name)) } } } } +private fun createMappingRow( + spinner: Spinner, + label: String, +) = LinearLayout(spinner.context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + // Baseline alignment can push wrapped labels below the row's measured height. + isBaselineAligned = false + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + addView(spinner) + addView( + MaterialTextView(context).apply { + layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) + text = label + textAlignment = View.TEXT_ALIGNMENT_CENTER + }, + ) +} + /** * Changes note type of multiple notes, displaying a message on success */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt index ce92f5375bfc..c89667b52a19 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt @@ -29,6 +29,8 @@ import com.ichi2.anki.R import com.ichi2.anki.ankiActivity import com.ichi2.anki.backend.DatabaseCorruption import com.ichi2.anki.backend.getDatabaseVersion +import com.ichi2.anki.common.destinations.PreferencesDestination +import com.ichi2.anki.common.destinations.navigate import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType.DIALOG_CONFIRM_DATABASE_CHECK @@ -355,13 +357,16 @@ class DatabaseErrorDialog : AsyncDialogFragment() { } } DIALOG_DB_LOCKED -> { - // If the database is locked, all we can do is ask the user to exit. alertDialog.show { title(CommonString.database_locked_title) message(text = message) positiveButton(CommonString.close) { closeCollectionAndFinish() } + // the user can change the 'AnkiDroid directory' to resolve the conflict + neutralButton(CommonString.settings) { + navigate(PreferencesDestination.Advanced) + } cancelable(false) } } @@ -585,7 +590,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { DIALOG_CONFIRM_DATABASE_CHECK -> res().getString(CommonString.check_db_warning) DIALOG_CONFIRM_RESTORE_BACKUP -> res().getString(CommonString.restore_backup) DIALOG_ONE_WAY_SYNC_FROM_SERVER -> res().getString(CommonString.backup_full_sync_from_server_question) - DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary) + DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary_new, res().getString(CommonString.col_path)) INCOMPATIBLE_DB_VERSION -> { var databaseVersion = -1 try { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt index f865edd7e019..533894f8727e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt @@ -21,7 +21,7 @@ import com.ichi2.anki.databinding.DialogLocaleSelectionBinding import com.ichi2.anki.dialogs.LocaleSelectionDialog.LocaleListAdapter.TextViewHolder import com.ichi2.anki.servicelayer.LanguageHintService import com.ichi2.ui.AccessibleSearchView -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.TypedFilter import com.ichi2.utils.cancelable import com.ichi2.utils.customView @@ -55,7 +55,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { style: Int, ) { super.setupDialog(dialog, style) - dialog.window?.let { resizeWhenSoftInputShown(it) } + setDialogKeyboardResize(dialog) // this is required for the keyboard to appear: https://stackoverflow.com/a/10133603/ dialog.window?.clearFlags(FLAG_NOT_FOCUSABLE or FLAG_ALT_FOCUSABLE_IM) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt index a0756be4b046..eb1ac5f9aba7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt @@ -39,6 +39,20 @@ class TagsArrayAdapter( val binding: ItemTagBinding, ) : RecyclerView.ViewHolder(binding.root) { internal lateinit var node: TagTreeNode + private set + + internal fun bindNode(node: TagTreeNode) { + clearNodeBinding() + this.node = node + node.vh = this + } + + internal fun clearNodeBinding() { + // If a note already has a replacement holder, preserve its binding. + if (::node.isInitialized && node.vh === this) { + node.vh = null + } + } @get:VisibleForTesting(otherwise = VisibleForTesting.NONE) val text: String @@ -63,9 +77,6 @@ class TagsArrayAdapter( * we want to save the [isExpanded] state of child tags, even if the parent is not expanded. * When we expand a tag, we do not want to walk the tree to determine how many nodes were added or removed. * @param isExpanded whether the node is expanded or not - * @param subtreeCheckedCnt The number of checked nodes in the subtree (self included). This exists - * because we want to dynamically turn a checkbox from unchecked into indeterminate if at least one of its - * descendants is checked, or from indeterminate to unchecked if all of its descendants are unchecked. * @param vh The reference to currently bound [ViewHolder]. A node bound with some [ViewHolder] must have [vh] nonnull. * @see onBindViewHolder for the binding */ @@ -76,7 +87,6 @@ class TagsArrayAdapter( val level: Int, var subtreeSize: Int, var isExpanded: Boolean, - var subtreeCheckedCnt: Int, var vh: ViewHolder?, ) { /** @@ -120,38 +130,31 @@ class TagsArrayAdapter( /** * Set the cycle style of the node's [vh]'s checkbox. - * For nodes without checked descendants: CHECKED -> UNCHECKED -> CHECKED -> ... - * For nodes with checked descendants: CHECKED -> INDETERMINATE -> CHECKED -> ... + * For nodes without selected descendants: CHECKED -> UNCHECKED -> CHECKED -> ... + * For nodes with checked or indeterminate descendants: CHECKED -> INDETERMINATE -> CHECKED -> ... * * @param tags The [TagsList] that manages tags. */ fun updateCheckBoxCycleStyle(tags: TagsList) { - val realSubtreeCnt = subtreeCheckedCnt - if (tags.isChecked(tag)) 1 else 0 - val hasDescendantChecked = realSubtreeCnt > 0 - vh?.binding?.checkBoxView?.cycleIndeterminateToChecked = hasDescendantChecked - vh?.binding?.checkBoxView?.cycleCheckedToIndeterminate = hasDescendantChecked + val hasSelectedDescendants = tags.hasSelectedDescendants(tag) + vh?.binding?.checkBoxView?.cycleIndeterminateToChecked = hasSelectedDescendants + vh?.binding?.checkBoxView?.cycleCheckedToIndeterminate = hasSelectedDescendants } /** - * When the checkbox of the node changes, update [subtreeCheckedCnt]s of itself and its ancestors. - * If the checkbox is INDETERMINATE now, it must be CHECKED before according to the checkbox cycle style. - * @see updateCheckBoxCycleStyle - * - * Update the checkbox to INDETERMINATE if it was UNCHECK and now [subtreeCheckedCnt] > 0. - * Update the checkbox to UNCHECK if it was INDETERMINATE and now [subtreeCheckedCnt] == 0. + * Update ancestor states from the complete selection, independently of which nodes are visible. + * Preserve tags present on some notes even when their last selected descendant is cleared. * * @param tags The [TagsList] that manages tags. */ fun onCheckStateChanged(tags: TagsList) { - val delta = if (checkBoxState == CHECKED) 1 else -1 - fun update(node: TagTreeNode) { - node.subtreeCheckedCnt += delta - if (node.checkBoxState == UNCHECKED && node.subtreeCheckedCnt > 0) { + val hasSelectedDescendants = tags.hasSelectedDescendants(node.tag) + if (!tags.isChecked(node.tag) && hasSelectedDescendants) { tags.setIndeterminate(node.tag) node.checkBoxState = INDETERMINATE } - if (node.checkBoxState == INDETERMINATE && node.subtreeCheckedCnt == 0) { + if (tags.isIndeterminate(node.tag) && !tags.isPartiallySelected(node.tag) && !hasSelectedDescendants) { tags.uncheck(node.tag) node.checkBoxState = UNCHECKED } @@ -224,7 +227,7 @@ class TagsArrayAdapter( tags.sort() } - /** Refresh cached subtree counts after bulk selection changes */ + /** Refresh the tag tree and checkbox states after bulk selection changes. */ fun notifyCheckedStatusesChanged() { rebuildTagTree() notifyDataSetChanged() @@ -276,8 +279,7 @@ class TagsArrayAdapter( position: Int, ) { val binding = holder.binding - holder.node = getVisibleTagTreeNode(position)!! - holder.node.vh = holder + holder.bindNode(getVisibleTagTreeNode(position)!!) holder.itemView.tag = holder.node.tag if (hasVisibleNestedTag) { @@ -304,6 +306,11 @@ class TagsArrayAdapter( holder.node.updateCheckBoxCycleStyle(tags) } + override fun onViewRecycled(holder: ViewHolder) { + holder.clearNodeBinding() + super.onViewRecycled(holder) + } + /** * Find the [TagTreeNode] of the [index]-th visible tag. * Implemented by walking the tree using [TagTreeNode.subtreeSize]. @@ -361,14 +368,13 @@ class TagsArrayAdapter( } hasVisibleNestedTag = false val stack = Stack() - treeRoot = TagTreeNode("", null, ArrayList(), -1, 0, true, 0, null) + treeRoot = TagTreeNode("", null, ArrayList(), -1, 0, true, null) stack.add(treeRoot) tagToNode.clear() fun stackPopAndPushUp() { val popped = stack.pop() stack.peek().subtreeSize += popped.getContributeSize() - stack.peek().subtreeCheckedCnt += popped.subtreeCheckedCnt } for (tag in filteredList) { // root will never be popped @@ -381,7 +387,7 @@ class TagsArrayAdapter( } val parent = stack.peek() val node = - TagTreeNode(tag, parent, ArrayList(), parent.level + 1, 1, tagToIsExpanded[tag]!!, if (tags.isChecked(tag)) 1 else 0, null) + TagTreeNode(tag, parent, ArrayList(), parent.level + 1, 1, tagToIsExpanded[tag]!!, null) parent.children.add(node) tagToNode[tag] = node stack.add(node) 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 0a63dc85b07e..5d230e702bb5 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 @@ -13,7 +13,6 @@ 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 @@ -48,7 +47,7 @@ import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.ui.AccessibleSearchView -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.TagsUtil import com.ichi2.utils.customView import com.ichi2.utils.getInputField @@ -161,7 +160,6 @@ class TagsDialog : AnalyticsDialogFragment { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - resizeWhenSoftInputShown(requireActivity().window) type = requireArguments().requireParcelable(ARG_DIALOG_TYPE) isCancelable = true @@ -266,7 +264,7 @@ class TagsDialog : AnalyticsDialogFragment { positiveButton?.isEnabled = true } - dialog.window?.updateSoftInputMode(resources.configuration) + dialog.updateSoftInputMode(resources.configuration) return dialog } @@ -307,15 +305,11 @@ class TagsDialog : AnalyticsDialogFragment { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - dialog?.window?.updateSoftInputMode(newConfig) + dialog?.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 Dialog.updateSoftInputMode(configuration: Configuration) { + setDialogKeyboardResize(this, resize = configuration.screenHeightDp >= COMPACT_HEIGHT_DP) } private fun radioButtonIdToCardState(id: Int) = diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt index 63724b6d255e..8959c45cbc5c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt @@ -42,7 +42,7 @@ class TagsList( /** * A Set containing the currently selected tags */ - private val checkedTags: MutableSet = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) + private val checkedTags = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) /** * Tags present on some notes, plus unchecked ancestors of selected tags. @@ -50,7 +50,7 @@ class TagsList( private val indeterminateTags: MutableSet /** Tags present on some notes whose own selection has not been changed by the user. */ - private val partiallySelectedTags: MutableSet = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) + private val partiallySelectedTags = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) /** * List of all available tags @@ -114,6 +114,17 @@ class TagsList( */ fun isIndeterminate(tag: String): Boolean = indeterminateTags.contains(tag) + /** Whether the tag itself is partially selected, independently of its descendants. */ + internal fun isPartiallySelected(tag: String): Boolean = partiallySelectedTags.contains(tag) + + /** Checks the complete selection, including descendants hidden by the dialog's filter. */ + internal fun hasSelectedDescendants(tag: String): Boolean { + val prefix = "$tag::" + // Descendants are contiguous in these case-insensitive sets; only the first candidate is needed. + return checkedTags.ceiling(prefix)?.startsWith(prefix, ignoreCase = true) == true || + partiallySelectedTags.ceiling(prefix)?.startsWith(prefix, ignoreCase = true) == true + } + /** * Adds a tag to the list if it is not already present. * If the tag is hierarchical, its ancestors will also be added temporarily. @@ -258,7 +269,9 @@ class TagsList( for (tag in allTags) { addAncestors(tag) } - for (tag in checkedTags) { + // Partially selected descendants also need their ancestors prioritized and expanded. + // Take a snapshot because marking ancestors modifies indeterminateTags. + for (tag in checkedTags + indeterminateTags) { markAncestorsIndeterminate(tag) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt new file mode 100644 index 000000000000..3618819e56f2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.work.WorkManager +import androidx.work.await +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.backupLock +import com.ichi2.anki.common.destinations.LauncherDestination +import com.ichi2.anki.toIntent +import com.ichi2.anki.worker.UniqueWorkNames +import com.ichi2.anki.worker.mediaSyncLock +import com.ichi2.anki.worker.syncLock +import com.jakewharton.processphoenix.ProcessPhoenix +import kotlinx.coroutines.sync.Mutex +import timber.log.Timber +import java.io.File + +/** + * Restarts the app once background work has finished and the collection is closed. + * + * Sync work is cancelled once the sync lock is held, since it carries the current + * profile's AnkiWeb key. The work locks stay held until ProcessPhoenix kills this + * process, so nothing can start and reopen the collection in the meantime. + */ +suspend fun safeRestartApp(context: Context) { + Timber.i("Restarting the app") + syncLock.holdUnlessThrows { + WorkManager.getInstance(context).run { + cancelUniqueWork(UniqueWorkNames.SYNC).await() + cancelUniqueWork(UniqueWorkNames.SYNC_MEDIA).await() + } + mediaSyncLock.holdUnlessThrows { + backupLock.holdUnlessThrows { + CollectionManager.ensureClosed() + ProcessPhoenix.triggerRebirth(context, LauncherDestination.toIntent(context)) + } + } + } +} + +/** Holds this lock while [block] runs, and keeps holding it unless [block] throws. */ +private suspend inline fun Mutex.holdUnlessThrows(block: () -> Unit) { + lock() + var returned = false + try { + block() + returned = true + } finally { + if (!returned) unlock() + } +} + +/** True in the short-lived ProcessPhoenix process, which only relaunches the app. */ +fun isPhoenixProcess(): Boolean = isPhoenixProcessName(currentProcessName()) + +@VisibleForTesting +internal fun isPhoenixProcessName(name: String?): Boolean = name?.endsWith(":phoenix") == true + +private fun currentProcessName(): String? = runCatching { File("/proc/self/cmdline").readText().trim { it <= ' ' } }.getOrNull() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index ba76d37ddebe..683525a8d8bc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -145,7 +145,8 @@ class ProfileManager private constructor( context(_: ProfileSwitchContext) fun switchActiveProfile(newProfileId: ProfileId) { Timber.i("Switching profile to ID: $newProfileId") - profileRegistry.setLastActiveProfileId(newProfileId) + // commit, not apply: the app restarts straight after a switch, and a pending apply() would be lost + profileRegistry.setLastActiveProfileId(newProfileId, commit = true) } private fun loadProfileData(profileId: ProfileId) { @@ -580,8 +581,11 @@ class ProfileManager private constructor( return id?.let { ProfileId(it) } } - fun setLastActiveProfileId(id: ProfileId) { - globalPrefs.edit { putString(KEY_LAST_ACTIVE_PROFILE_ID, id.value) } + fun setLastActiveProfileId( + id: ProfileId, + commit: Boolean = false, + ) { + globalPrefs.edit(commit = commit) { putString(KEY_LAST_ACTIVE_PROFILE_ID, id.value) } } fun getWebViewProfileId(): ProfileId? { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt new file mode 100644 index 000000000000..d3dfee316827 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.backupLock +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.BlockReason +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.SafetyCheck +import com.ichi2.anki.worker.mediaSyncLock +import com.ichi2.anki.worker.syncLock +import kotlinx.coroutines.sync.Mutex + +/** + * Blocks a switch while the collection is syncing in the background. + * + * Only the background sync needs this: a foreground sync holds a modal progress + * dialog, so the user cannot reach the switch until it finishes. + */ +fun syncCheck() = lockCheck(syncLock, BlockReason.SYNC_IN_PROGRESS) + +/** Blocks a switch while media is syncing. */ +fun mediaSyncCheck() = lockCheck(mediaSyncLock, BlockReason.MEDIA_SYNC_IN_PROGRESS) + +/** Blocks a switch while a background backup is running. */ +fun backupCheck() = lockCheck(backupLock, BlockReason.BACKUP_IN_PROGRESS) + +/** + * Reads a lock that the work holds only while it runs, so work that is queued but + * waiting, such as a sync waiting for the network, does not block the switch: + * there is nothing to interrupt yet, and [safeRestartApp] holds the same lock, so the + * work cannot start part way through the restart. + * + * [safeRestartApp] waits for work that is still running, so a check exists to tell the + * user why the switch cannot happen yet rather than for safety. + */ +private fun lockCheck( + lock: Mutex, + reason: BlockReason, +) = SafetyCheck { reason.takeIf { lock.isLocked } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt index 85adb854621a..f7ae4eee783f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt @@ -43,8 +43,12 @@ class ProfileSwitchGuard( ) : Result() } + // TODO: COLLECTION_BUSY has no check yet: CollectionManager serialises on a + // private queue it does not expose. Closing the collection already waits for a + // running operation, so this is about warning the user rather than about safety. enum class BlockReason { BACKUP_IN_PROGRESS, + SYNC_IN_PROGRESS, MEDIA_SYNC_IN_PROGRESS, COLLECTION_BUSY, } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index d6113e638c43..fccd2f992dbe 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -52,6 +52,7 @@ import com.ichi2.utils.FileUtil.internalizeUri import com.ichi2.utils.Permissions.arePermissionsDefinedInManifest import net.ankiweb.rsdroid.BackendException import net.ankiweb.rsdroid.exceptions.BackendDeckIsFilteredException +import net.ankiweb.rsdroid.exceptions.BackendNotFoundException import org.json.JSONArray import org.json.JSONException import timber.log.Timber @@ -84,9 +85,6 @@ import java.io.IOException * */ -// TODO: Consider streaming Cursor results instead of materializing all rows -// to avoid potential OOM for large queries. -// Tracked in: https://github.com/ankidroid/Anki-Android/issues/20253 class CardContentProvider : ContentProvider() { private val applicationStartup = ContentProviderStartup() @@ -123,6 +121,48 @@ class CardContentProvider : ContentProvider() { */ private val defaultNoteProjectionDbAccess = FlashCardsContract.Note.DEFAULT_COLUMNS + private val cardColumns: Map Any?> = + mapOf( + FlashCardsContract.Card._ID to { card, _ -> card.id }, + FlashCardsContract.Card.NOTE_ID to { card, _ -> card.nid }, + FlashCardsContract.Card.CARD_ORD to { card, _ -> card.ord }, + FlashCardsContract.Card.CARD_NAME to { card, col -> cardName(card, col) }, + FlashCardsContract.Card.DECK_ID to { card, _ -> card.did }, + FlashCardsContract.Card.REPS to { card, _ -> card.reps }, + FlashCardsContract.Card.LAPSES to { card, _ -> card.lapses }, + FlashCardsContract.Card.TYPE to { card, _ -> card.type.code }, + FlashCardsContract.Card.ORIGINAL_DECK_ID to { card, _ -> card.oDid }, + FlashCardsContract.Card.QUESTION to { card, col -> card.renderOutput(col).questionWithFixedSoundTags() }, + FlashCardsContract.Card.ANSWER to { card, col -> card.renderOutput(col).answerWithFixedSoundTags() }, + FlashCardsContract.Card.QUESTION_SIMPLE to { card, col -> card.renderOutput(col).questionText }, + FlashCardsContract.Card.ANSWER_SIMPLE to { card, col -> card.renderOutput(col, false).answerText }, + FlashCardsContract.Card.ANSWER_PURE to { card, col -> card.pureAnswer(col) }, + FlashCardsContract.Card.RAW_QUEUE to { card, _ -> card.queue.code }, + FlashCardsContract.Card.RAW_DUE to { card, _ -> card.due }, + FlashCardsContract.Card.RAW_ORIGINAL_DUE to { card, _ -> card.oDue }, + FlashCardsContract.Card.INTERVAL to { card, _ -> card.ivl }, + FlashCardsContract.Card.RAW_SM2_FACTOR to { card, _ -> card.factor }, + FlashCardsContract.Card.RAW_LEFT to { card, _ -> card.left }, + FlashCardsContract.Card.ORIGINAL_POSITION to { card, _ -> card.originalPosition }, + FlashCardsContract.Card.RAW_CUSTOM_DATA to { card, _ -> card.customData }, + FlashCardsContract.Card.FSRS_STABILITY to { card, _ -> card.memoryStateStability }, + FlashCardsContract.Card.FSRS_DIFFICULTY to { card, _ -> card.memoryStateDifficulty }, + FlashCardsContract.Card.FSRS_DESIRED_RETENTION to { card, _ -> card.fsrsDesiredRetention }, + FlashCardsContract.Card.FSRS_DECAY to { card, _ -> card.decay }, + FlashCardsContract.Card.LAST_REVIEW_TIME_SECONDS to { card, _ -> card.lastReviewTimeSecs }, + FlashCardsContract.Card.FLAGS to { card, _ -> card.flags and 7 }, // return only 3 first bits + ) + + private fun cardName( + card: Card, + col: Collection, + ): String = + try { + card.template(col).name + } catch (je: JSONException) { + throw IllegalArgumentException("Card is using an invalid template", je) + } + private fun sanitizeNoteProjection(projection: Array?): Array { if (projection.isNullOrEmpty()) { return defaultNoteProjectionDbAccess @@ -433,6 +473,9 @@ class CardContentProvider : ContentProvider() { val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS val query = selection ?: "" + // validate the columns + columns.forEach { cardColumnAccessor(it) } + val cardIds = try { col.findCards(query) @@ -446,21 +489,28 @@ class CardContentProvider : ContentProvider() { // Fast path: Only if _id is requested val onlyRequestingId = columns.singleOrNull() == FlashCardsContract.Card._ID - if (onlyRequestingId) { - // Return IDs without fetching card objects - val rv = MatrixCursor(columns, cardIds.size) - for (cardId in cardIds) { - rv.newRow().add(cardId) - } - rv - } else { - // Get all requested fields - val rv = MatrixCursor(columns, cardIds.size) - for (cardId in cardIds) { - val card = col.getCard(cardId) - addCardToCursor(card, rv, col, columns) + LazyCursor(columns, cardIds.size) { position, row -> + val cardId = cardIds[position] + if (onlyRequestingId) { + row.newRow().add(cardId) + } else { + val currentCol = getColUnsafe() + val card = + try { + currentCol.getCard(cardId) + } catch (e: BackendNotFoundException) { + Timber.d(e, "card %d was deleted after the query returned", cardId) + null + } + if (card != null) { + addCardToCursor(card, row, currentCol, columns) + } else { + val rb = row.newRow() + for (column in columns) { + rb.add(if (column == FlashCardsContract.Card._ID) cardId else null) + } + } } - rv } } CARD_ID -> { @@ -1203,50 +1253,15 @@ class CardContentProvider : ContentProvider() { col: Collection, columns: Array, ) { - val cardName: String = - try { - currentCard.template(col).name - } catch (je: JSONException) { - throw IllegalArgumentException("Card is using an invalid template", je) - } - val question = currentCard.renderOutput(col).questionWithFixedSoundTags() - val answer = currentCard.renderOutput(col).answerWithFixedSoundTags() val rb = rv.newRow() for (column in columns) { - when (column) { - FlashCardsContract.Card._ID -> rb.add(currentCard.id) - FlashCardsContract.Card.NOTE_ID -> rb.add(currentCard.nid) - FlashCardsContract.Card.CARD_ORD -> rb.add(currentCard.ord) - FlashCardsContract.Card.CARD_NAME -> rb.add(cardName) - FlashCardsContract.Card.DECK_ID -> rb.add(currentCard.did) - FlashCardsContract.Card.REPS -> rb.add(currentCard.reps) - FlashCardsContract.Card.LAPSES -> rb.add(currentCard.lapses) - FlashCardsContract.Card.TYPE -> rb.add(currentCard.type.code) - FlashCardsContract.Card.ORIGINAL_DECK_ID -> rb.add(currentCard.oDid) - FlashCardsContract.Card.QUESTION -> rb.add(question) - FlashCardsContract.Card.ANSWER -> rb.add(answer) - FlashCardsContract.Card.QUESTION_SIMPLE -> rb.add(currentCard.renderOutput(col).questionText) - FlashCardsContract.Card.ANSWER_SIMPLE -> rb.add(currentCard.renderOutput(col, false).answerText) - FlashCardsContract.Card.ANSWER_PURE -> rb.add(currentCard.pureAnswer(col)) - FlashCardsContract.Card.RAW_QUEUE -> rb.add(currentCard.queue.code) - FlashCardsContract.Card.RAW_DUE -> rb.add(currentCard.due) - FlashCardsContract.Card.RAW_ORIGINAL_DUE -> rb.add(currentCard.oDue) - FlashCardsContract.Card.INTERVAL -> rb.add(currentCard.ivl) - FlashCardsContract.Card.RAW_SM2_FACTOR -> rb.add(currentCard.factor) - FlashCardsContract.Card.RAW_LEFT -> rb.add(currentCard.left) - FlashCardsContract.Card.ORIGINAL_POSITION -> rb.add(currentCard.originalPosition) - FlashCardsContract.Card.RAW_CUSTOM_DATA -> rb.add(currentCard.customData) - FlashCardsContract.Card.FSRS_STABILITY -> rb.add(currentCard.memoryStateStability) - FlashCardsContract.Card.FSRS_DIFFICULTY -> rb.add(currentCard.memoryStateDifficulty) - FlashCardsContract.Card.FSRS_DESIRED_RETENTION -> rb.add(currentCard.fsrsDesiredRetention) - FlashCardsContract.Card.FSRS_DECAY -> rb.add(currentCard.decay) - FlashCardsContract.Card.LAST_REVIEW_TIME_SECONDS -> rb.add(currentCard.lastReviewTimeSecs) - FlashCardsContract.Card.FLAGS -> rb.add(currentCard.flags and 7) // return only 3 first bits - else -> throw UnsupportedOperationException("Queue \"$column\" is unknown") - } + rb.add(cardColumnAccessor(column)(currentCard, col)) } } + private fun cardColumnAccessor(column: String): (Card, Collection) -> Any? = + cardColumns[column] ?: throw UnsupportedOperationException("Column \"$column\" is unknown") + private fun addReviewInfoToCursor( currentCard: Card, nextReviewTimesJson: JSONArray, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt new file mode 100644 index 000000000000..dd6e2090db40 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.provider + +import android.database.AbstractCursor +import android.database.MatrixCursor + +/** + * A cursor that loads rows on demand and retains at most one loaded row. + * + * Creating the cursor, reading its count, and moving between positions do not load any rows. + * Reading a column calls [loadRow], which must add exactly one row to the supplied [MatrixCursor]. + * Further reads at the same position reuse that row. Moving discards it, so reading it again after + * moving away calls [loadRow] again and may yield updated values. Android can copy rows into a + * cursor window without this cursor retaining all the results. + * + * For example, defer fetching card details until a result is read: + * ```kotlin + * val cursor = LazyCursor(arrayOf("_id", "reps"), cardIds.size) { position, row -> + * val card = col.getCardOrNull(cardIds[position]) + * row.newRow().add(card.id).add(card.reps) + * } + * ``` + */ +internal class LazyCursor( + private val columns: Array, + private val rowCount: Int, + loadRow: (Int, MatrixCursor) -> Unit, +) : AbstractCursor() { + private var loadRow: ((Int, MatrixCursor) -> Unit)? = loadRow + private var row: MatrixCursor? = null + + override fun getCount(): Int = rowCount + + override fun getColumnNames(): Array = columns + + override fun onMove( + oldPosition: Int, + newPosition: Int, + ): Boolean { + check(!isClosed) + row?.close() + row = null + return true + } + + private fun currentRow(): MatrixCursor { + check(!isClosed) + checkPosition() + row?.let { return it } + val next = MatrixCursor(columns, 1) + try { + loadRow!!(position, next) + check(next.count == 1) + next.moveToFirst() + row = next + } catch (e: Exception) { + next.close() + throw e + } + return next + } + + override fun getString(column: Int): String? = currentRow().getString(column) + + override fun getShort(column: Int): Short = currentRow().getShort(column) + + override fun getInt(column: Int): Int = currentRow().getInt(column) + + override fun getLong(column: Int): Long = currentRow().getLong(column) + + override fun getFloat(column: Int): Float = currentRow().getFloat(column) + + override fun getDouble(column: Int): Double = currentRow().getDouble(column) + + override fun getBlob(column: Int): ByteArray? = currentRow().getBlob(column) + + override fun getType(column: Int): Int = currentRow().getType(column) + + override fun isNull(column: Int): Boolean = currentRow().isNull(column) + + override fun close() { + row?.close() + row = null + loadRow = null + super.close() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index 265d8c6aa04c..8f94360dc9d0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -39,7 +39,7 @@ import com.ichi2.anki.utils.ext.getParcelableCompat import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.utils.showDialogFragment -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.Permissions import com.ichi2.utils.customView import com.ichi2.utils.negativeButton @@ -125,7 +125,7 @@ class AddEditReminderDialog : DialogFragment() { setUpCardThresholdInput() setUpOnlyNotifyIfNoReviewsCheckbox() - dialog.window?.let { resizeWhenSoftInputShown(it) } + setDialogKeyboardResize(dialog) return dialog } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt index 8673fd41fb62..a3e71cbe688a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt @@ -17,6 +17,7 @@ import com.google.android.material.materialswitch.MaterialSwitch import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString import com.ichi2.anki.R +import com.ichi2.anki.common.utils.ext.setBitFlag import com.ichi2.anki.databinding.ItemScheduleRemindersBinding import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.ui.internationalization.sentenceCase @@ -131,12 +132,7 @@ class ScheduleRemindersAdapter( textView: TextView, setStrikethrough: Boolean, ) { - textView.paintFlags = - if (setStrikethrough) { - textView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - } else { - textView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } + textView.paintFlags = textView.paintFlags.setBitFlag(Paint.STRIKE_THRU_TEXT_FLAG, enabled = setStrikethrough) } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt index 206c1fa497ba..ff5683440a44 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -54,6 +54,11 @@ import com.ichi2.anki.common.android.R as CommonR * * This service can be triggered in one of two possible ways, depending on whether the notification * being fired is a recurring notification or a one-time snoozed notification. See [NotificationServiceAction]. + * + * TODO: Rename and reorganize the (handle/send/fire)ReviewReminderNotification functions for better readability and separation of concerns. + * These three verbs are way too similar. The "send" method should be split into "validate" and "hydrate". "send" sometimes aborts and does nothing. + * The distinction between "handle" and "send" should be made more clear. All uses of "fire" in the codebase, including in documentation, + * should be aligned to mean "trigger the OS display unconditionally." */ class NotificationService : AnkiBroadcastReceiver() { companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt index 7f5f82d09f61..b71f07b60506 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt @@ -53,6 +53,7 @@ import com.ichi2.anki.dialogs.tags.TagsDialogFactory import com.ichi2.anki.dialogs.tags.TagsDialogListener import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.preferences.reviewer.ViewerAction +import com.ichi2.anki.preferences.reviewer.WhiteboardAction import com.ichi2.anki.previewer.CardViewerActivity import com.ichi2.anki.previewer.CardViewerFragment import com.ichi2.anki.previewer.TypeAnswer @@ -384,11 +385,14 @@ class ReviewerFragment : binding.root.setOnGenericMotionListener { _, event -> bindingMap.onGenericMotionEvent(event) } - if (bindingMap.isBound(Gesture.SHAKE)) { + if (isBound(Gesture.SHAKE)) { shakeDetector = AnkiShakeDetector.createInstance(requireContext(), this) } } + private fun isBound(gesture: Gesture): Boolean = + bindingMap.isBound(gesture) || BindingMap(sharedPrefs(), WhiteboardAction.entries).isBound(gesture) + private fun setupAnswerButtons() { if (!Prefs.showAnswerButtons) { binding.answerArea.isVisible = false diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt index 51913866d04c..7850345d2a0d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt @@ -17,6 +17,7 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.PopupWindow import androidx.activity.OnBackPressedCallback +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu @@ -59,7 +60,8 @@ class WhiteboardFragment : Fragment(R.layout.fragment_whiteboard), PopupMenu.OnMenuItemClickListener, DispatchKeyEventListener { - private val viewModel: WhiteboardViewModel by viewModels { + @VisibleForTesting + internal val viewModel: WhiteboardViewModel by viewModels { WhiteboardViewModel.factory(AnkiDroidApp.sharedPrefs()) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/HanUnificationDetector.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/HanUnificationDetector.kt new file mode 100644 index 000000000000..714f780842e3 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/HanUnificationDetector.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Snowiee + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.utils + +/** + * Detects Han Unification issues in note field content. + * + * Han Unification is a Unicode feature where visually distinct Chinese, Japanese, and Korean + * characters are assigned the same code point. This can cause Japanese kanji to render as + * Chinese hanzi (or vice versa) depending on the system font, unless the HTML explicitly + * specifies the language via the `lang` attribute. + * + * Example: U+9AA8 (骨) renders differently in Japanese vs Chinese without lang="ja" or lang="zh" + * + * This detector is used during note creation/editing to warn users about potential rendering + * issues before they encounter them during review. + * + * @see Han Unification on Wikipedia + */ + +object HanUnificationDetector { + /** + * Common CJK characters known to have significant visual differences between + * Japanese and Chinese rendering. This is a curated list for detection for now. + * + * Source: https://github.com/ankidroid/Anki-Android/issues/19431 + */ + private val KNOWN_PROBLEMATIC_CHARS = + setOf( + '\u9AA8', // 骨 + '\u76F4', // 直 + '\u76F8', // 相 + '\u79C1', // 私 + '\u8FBC', // 込 + '\u904E', // 過 + '\u98DF', // 食 + '\u6700', // 最 + '\u5408', // 合 + '\u5009', // 倉 + '\u5C64', // 層 + '\u66FE', // 曾 + '\u671D', // 朝 + '\u6CBB', // 治 + '\u7384', // 玄 + '\u7DF4', // 練 + '\u7F72', // 署 + '\u89D2', // 角 + '\u8AAA', // 說 + '\u8D08', // 贈 + '\u9038', // 逸 + '\u9244', // 鉄 + '\u96C6', // 集 + ) + + /** + * Detects potential Han Unification issues in text content. + * + * Checks if the text contains known CJK characters that render differently + * in Japanese vs Chinese contexts without proper language specification. + * + * @param text The text content to analyze (plain text, not HTML) + * @return true if any problematic characters are found, false otherwise + */ + fun detectIssue(text: String): Boolean { + if (text.isEmpty()) { + return false + } + + // Early exit on first problematic character found + return text.any { char -> KNOWN_PROBLEMATIC_CHARS.contains(char) } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/AppLoadedFromBackupWorkaround.kt b/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/AppLoadedFromBackupWorkaround.kt index 262f84dac262..747383b2fc40 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/AppLoadedFromBackupWorkaround.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/AppLoadedFromBackupWorkaround.kt @@ -34,8 +34,6 @@ object AppLoadedFromBackupWorkaround { if (AnkiDroidApp.isInitialized) { return false } - // TODO: Timber likely does not work on this path - maybe add a check in IntentHandler - // #7630: Can be triggered with `adb shell bmgr restore com.ichi2.anki` after AnkiDroid settings are changed. // Application.onCreate() is not called if: // * The App was open diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt new file mode 100644 index 000000000000..65ad454bd628 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.worker + +import androidx.work.Data +import androidx.work.workDataOf +import anki.sync.syncAuth +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.sync.SyncAuth + +private const val SYNC_AUTH_KEY = "syncAuth" + +/** Preserve all sync settings, including the network timeout, when passing auth to either worker. */ +internal fun SyncAuth.toWorkData(): Data = workDataOf(SYNC_AUTH_KEY to toProto().toByteArray()) + +internal fun Data.toSyncAuth(): SyncAuth? { + getByteArray(SYNC_AUTH_KEY)?.let { return SyncAuth(anki.sync.SyncAuth.parseFrom(it)) } + + // Temporary workaround for an app update from <2.26.0 + val hkey = getString("hkey") ?: return null + return SyncAuth( + syncAuth { + this.hkey = hkey + getString("endpoint")?.let { endpoint = it } + ioTimeoutSecs = Prefs.networkTimeoutSecs + }, + ) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt index 54c2e7d95df7..f634050b8635 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt @@ -12,7 +12,6 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.Constraints import androidx.work.CoroutineWorker -import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo import androidx.work.NetworkType @@ -22,7 +21,6 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters import anki.sync.MediaSyncProgress -import anki.sync.syncAuth import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString @@ -39,9 +37,16 @@ import com.ichi2.anki.utils.ext.trySetForeground import com.ichi2.utils.TruncatedString import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex import net.ankiweb.rsdroid.Backend import timber.log.Timber +/** + * Held while a media sync runs. A profile switch holds it until the process dies, + * so a media sync cannot start part way through a restart. + */ +val mediaSyncLock = Mutex() + class SyncMediaWorker( context: Context, parameters: WorkerParameters, @@ -56,15 +61,12 @@ class SyncMediaWorker( override suspend fun doWork(): Result { Timber.v("SyncMediaWorker::doWork") + return mediaSyncLock.withLockUnlessSwitching { doWorkHoldingLock() } + } + private suspend fun doWorkHoldingLock(): Result { try { - val auth = - syncAuth { - hkey = inputData.getString(HKEY_KEY)!! - inputData.getString(ENDPOINT_KEY)?.let { - endpoint = it - } - }.let(::SyncAuth) + val auth = requireNotNull(inputData.toSyncAuth()) // The collection must be open, but we should not block collection operations while // `syncMedia` is executing, the app should be usable during a background media sync @@ -193,8 +195,6 @@ class SyncMediaWorker( } companion object { - private const val HKEY_KEY = "hkey" - private const val ENDPOINT_KEY = "endpoint" const val NOTIFICATION_UPDATE_RATE_MS = 500L fun getWorkRequest(auth: SyncAuth): OneTimeWorkRequest { @@ -204,15 +204,8 @@ class SyncMediaWorker( .setRequiredNetworkType(NetworkType.CONNECTED) .build() - val data = - Data - .Builder() - .putString(HKEY_KEY, auth.hkey) - .putString(ENDPOINT_KEY, auth.endpoint) - .build() - return OneTimeWorkRequestBuilder() - .setInputData(data) + .setInputData(auth.toWorkData()) .setConstraints(constraints) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() 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 6cae7ab73c93..6a804b9b564f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt @@ -20,7 +20,6 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import anki.collection.Progress import anki.sync.SyncCollectionResponse -import anki.sync.syncAuth import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol @@ -39,9 +38,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import timber.log.Timber import kotlin.coroutines.cancellation.CancellationException +/** + * Held while a background collection sync runs. A profile switch holds it until + * the process dies, so a sync cannot start part way through a restart. + */ +val syncLock = Mutex() + /** * Syncs collection and media in the background. * @@ -70,18 +76,13 @@ class SyncWorker( override suspend fun doWork(): Result { Timber.v("SyncWorker::doWork") + return syncLock.withLockUnlessSwitching { doWorkHoldingLock() } + } + + private suspend fun doWorkHoldingLock(): Result { trySetForeground(getForegroundInfo()) - val hkey = - inputData.getString(HKEY_KEY) - ?: return Result.failure() - val auth = - syncAuth { - this.hkey = hkey - inputData.getString(ENDPOINT_KEY)?.let { - endpoint = it - } - }.let(::SyncAuth) + val auth = inputData.toSyncAuth() ?: return Result.failure() val shouldSyncMedia = inputData.getBoolean(SYNC_MEDIA_KEY, false) try { @@ -229,8 +230,6 @@ class SyncWorker( } companion object { - private const val HKEY_KEY = "hkey" - private const val ENDPOINT_KEY = "endpoint" private const val SYNC_MEDIA_KEY = "syncMedia" fun start( @@ -247,8 +246,7 @@ class SyncWorker( val data = Data .Builder() - .putString(HKEY_KEY, syncAuth.hkey) - .putString(ENDPOINT_KEY, syncAuth.endpoint) + .putAll(syncAuth.toWorkData()) .putBoolean(SYNC_MEDIA_KEY, syncMedia) .build() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt new file mode 100644 index 000000000000..f0cbf9796ca2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.worker + +import androidx.work.ListenableWorker.Result +import kotlinx.coroutines.sync.Mutex +import timber.log.Timber + +/** + * Runs [block] holding this lock. If a profile switch holds it, the work is dropped + * rather than rescheduled, since it carries the AnkiWeb key of the profile being left. + */ +internal suspend fun Mutex.withLockUnlessSwitching(block: suspend () -> Result): Result { + if (!tryLock()) { + Timber.i("Background work dropped: a profile switch is restarting the app") + return Result.failure() + } + try { + return block() + } finally { + unlock() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt index e69cc4aa4dc1..50ff13edf07c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt @@ -2,13 +2,14 @@ package com.ichi2.utils +import android.app.Dialog import android.content.Context import android.graphics.Insets import android.graphics.Point import android.os.Build -import android.view.Window import android.view.WindowInsets import android.view.WindowManager +import com.ichi2.anki.compat.CompatHelper object DisplayUtils { @Suppress("DEPRECATION") // #9333: defaultDisplay & getSize @@ -37,10 +38,16 @@ object DisplayUtils { return getDisplayDimensions(wm) } - /** Allow the window to be resized when an input method is shown, - * so that its contents are not covered by the input method */ - @Suppress("DEPRECATION") // 7110: SOFT_INPUT_ADJUST_RESIZE - fun resizeWhenSoftInputShown(window: Window) { - window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + /** + * Fit a floating dialog above the keyboard, or pan to its focused input when [resize] is false. + * + * Activities with edge-to-edge content should handle IME insets in their own layout. + */ + fun setDialogKeyboardResize( + dialog: Dialog, + resize: Boolean = true, + ) { + val window = dialog.window ?: return + CompatHelper.compat.setDialogKeyboardResize(window, resize) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt b/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt index fac9135b3903..ebbb3397a5bd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt @@ -33,7 +33,7 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.windows.permissions.PermissionsBottomSheet import com.ichi2.utils.Permissions.arePermissionsDefinedInManifest import timber.log.Timber -import kotlin.reflect.KMutableProperty +import kotlin.reflect.KMutableProperty0 object Permissions { @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -52,9 +52,9 @@ object Permissions { fun canPermissionBeRequested( activity: Activity, permission: String, - permissionRequestedFlag: KMutableProperty, + permissionRequestedFlag: KMutableProperty0, ): Boolean = - if (permissionRequestedFlag.getter.call()) { + if (permissionRequestedFlag.get()) { // Previous requests have been made, only request again if we are not blocked from requesting ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) } else { @@ -79,11 +79,11 @@ object Permissions { fun Fragment.requestPermissionThroughDialogOrSettings( activity: Activity, permission: String, - permissionRequestedFlag: KMutableProperty, + permissionRequestedFlag: KMutableProperty0, permissionRequestLauncher: ActivityResultLauncher, ) { if (canPermissionBeRequested(activity, permission, permissionRequestedFlag)) { - permissionRequestedFlag.setter.call(true) + permissionRequestedFlag.set(true) permissionRequestLauncher.launch(permission) } else { showToastAndOpenAppSettingsScreenForPermission(permission, CommonString.manually_grant_permissions) diff --git a/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml b/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml index 30aaf9c9a07d..9f1ffc1ba5e6 100644 --- a/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml +++ b/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml @@ -98,6 +98,8 @@ android:id="@+id/CardEditorModelText" android:layout_width="match_parent" android:layout_height="wrap_content" + android:maxLines="@integer/change_note_type_name_max_lines" + android:ellipsize="end" android:textDirection="locale" android:textStyle="bold" android:textAppearance="?attr/textAppearanceBodyLarge" /> @@ -154,21 +156,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@id/to_label" - android:layout_toStartOf="@+id/to_dropdown_arrow" android:layout_alignParentStart="true" android:background="@null" android:padding="0dp" android:minHeight="0dp" /> - - @@ -178,6 +169,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@android:color/transparent" + app:tabMode="fixed" app:tabMaxWidth="0dp" app:tabGravity="fill"/> @@ -189,4 +181,4 @@ /> - \ No newline at end of file + diff --git a/AnkiDroid/src/main/res/layout/dialog_fields.xml b/AnkiDroid/src/main/res/layout/dialog_fields.xml index 0a60821370fa..d59297f79471 100644 --- a/AnkiDroid/src/main/res/layout/dialog_fields.xml +++ b/AnkiDroid/src/main/res/layout/dialog_fields.xml @@ -10,12 +10,13 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:paddingTop="8dp" + android:paddingBottom="16dp" android:fadeScrollbars="false"> + android:paddingTop="16dp"> + android:paddingTop="16dp"> Databasis Is Vergrendel - Die AnkiDroid-databasis is in gebruik deur \'n ander toepassing. Maak asseblief die ander toepassing toe en heropen dan AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Onversoenbare Databasis-Weergawe Die databasis is \'n meer gevorderde weergawe as wat hierdie weergawe van AnkiDroid mee kan werk. Opgradeer AnkiDroid of downgrade die databasis om dit oop te maak\n\nOndersteunde weergawe: %1$d\nDatabasis weergawe: %2$d\n\nDie volgende herstelopsies sal jou huidige versameling oorskryf, moontlik met \'n versoenbare databasisweergawe: diff --git a/AnkiDroid/src/main/res/values-am/03-dialogs.xml b/AnkiDroid/src/main/res/values-am/03-dialogs.xml index 202ed1b91ba6..1ec5dfe344d0 100644 --- a/AnkiDroid/src/main/res/values-am/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-am/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ar/03-dialogs.xml b/AnkiDroid/src/main/res/values-ar/03-dialogs.xml index 98127d3260cd..537564fffddf 100644 --- a/AnkiDroid/src/main/res/values-ar/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ar/03-dialogs.xml @@ -129,7 +129,7 @@ تم قفل قاعدة البيانات - قاعدة بيانات آنكي درويد قَيد الاستخدام من قبل تطبيق آخر. الرجاء إغلاق ذاك التطبيق وفتح آنكي درويد مجددًا. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. إصدار قاعدة بيانات غير متوافق إصدار قاعدة البيانات يفوق أعلى إصدار يعمل مع آنكي دوريد. حدِّث آنكي درويد أو خفِّض إصدار قاعدة البيانات لفتحها.\n\nالإصدار المدعوم: %1$d\nإصدار قاعدة البيانات: %2$d\n\nخيارات الاسترجاع التالية يحتمل أن تستبدل بمجموعتك الحالية نسخةً ذات إصدار قاعدة بيانات متوافق: diff --git a/AnkiDroid/src/main/res/values-az/03-dialogs.xml b/AnkiDroid/src/main/res/values-az/03-dialogs.xml index 0ed372b2fe0c..18ed9dd31498 100644 --- a/AnkiDroid/src/main/res/values-az/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-az/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-be/03-dialogs.xml b/AnkiDroid/src/main/res/values-be/03-dialogs.xml index 2239c8945de6..a51e4aa2aa47 100644 --- a/AnkiDroid/src/main/res/values-be/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-be/03-dialogs.xml @@ -123,7 +123,7 @@ База даных заблакаваная - База даных AnkiDroid выкарыстоўваецца іншай праграмай. Закрыйце ўсе праграмы ўсе пабочныя праграмы і перазапусціце AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Несумяшчальная версія базы даных База даных была створана ў больш новай версіі праграмы і ў дадзены час з ёй немагчыма працаваць. Паспрабуйце абнавіць AnkiDroid або панізіць версію базы даных, каб адкрыць яе\n\nВерсія, якая падтрымліваецца: %1$d\nВерсія базы даных:%2$d\n\nНаступныя параметры аднаўлення перазапішуць вашу бягучую калекцыю, магчыма з сумяшчальнай версіяй базы даных. diff --git a/AnkiDroid/src/main/res/values-bg/03-dialogs.xml b/AnkiDroid/src/main/res/values-bg/03-dialogs.xml index 357dfef7e79c..bb764ff9fc62 100644 --- a/AnkiDroid/src/main/res/values-bg/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-bg/03-dialogs.xml @@ -117,7 +117,7 @@ Базата данни заключена - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Несъвместима версия на базата от данни The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-bn/03-dialogs.xml b/AnkiDroid/src/main/res/values-bn/03-dialogs.xml index 5f84559395c2..22ac560b4f49 100644 --- a/AnkiDroid/src/main/res/values-bn/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-bn/03-dialogs.xml @@ -117,7 +117,7 @@ ডাটাবেস লক করা আছে - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. বেমানান ডাটাবেস সংস্করণ The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ca/03-dialogs.xml b/AnkiDroid/src/main/res/values-ca/03-dialogs.xml index df7a0839e935..4e6e6fdc741b 100644 --- a/AnkiDroid/src/main/res/values-ca/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ca/03-dialogs.xml @@ -117,7 +117,7 @@ Base de dades bloquejada - Una altra aplicació fa servir la base de dades AnkiDroid. Tanqueu l’altra aplicació i torneu a obrir AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versió de base de dades incompatible La base de dades és una versió més avançada de la que pot funcionar aquesta versió d\'AnkiDroid. Actualitzeu AnkiDroid o feu una versió anterior de la base de dades per obrir-la\n\nVersió compatible: %1$d\nVersió de base de dades: %2$d\n\nLes següents opcions de restauració sobreescriuran la vostra col·lecció actual, possiblement amb una versió de base de dades compatible: diff --git a/AnkiDroid/src/main/res/values-ckb/01-core.xml b/AnkiDroid/src/main/res/values-ckb/01-core.xml index 2bc00e799ab0..a4e686b82afd 100644 --- a/AnkiDroid/src/main/res/values-ckb/01-core.xml +++ b/AnkiDroid/src/main/res/values-ckb/01-core.xml @@ -125,7 +125,7 @@ The card type for the current card was deleted. Read and write to the AnkiDroid database - access existing notes, cards, note types, and decks, as well as create new ones + ڕێگەپێدان بە گەشتن بە تێبینییەکان ،کارتەکان ،جۆری تێبینییەکان ،و دەستەکان ،لەگەڵ دروستکردنی هی نوێ Card browser Anki card diff --git a/AnkiDroid/src/main/res/values-ckb/03-dialogs.xml b/AnkiDroid/src/main/res/values-ckb/03-dialogs.xml index a391163131d6..3444c826968a 100644 --- a/AnkiDroid/src/main/res/values-ckb/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ckb/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-cs/03-dialogs.xml b/AnkiDroid/src/main/res/values-cs/03-dialogs.xml index fbe668d1243a..4bd744efcce1 100644 --- a/AnkiDroid/src/main/res/values-cs/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-cs/03-dialogs.xml @@ -123,7 +123,7 @@ Databáze uzamčena - Databázi AnkiDroid používá jiná aplikace. Zavřete prosím druhou aplikaci a znovu otevřete AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Nekompatibilní verze databáze Databáze je pokročilejší verze, než se kterou může tato verze AnkiDroid pracovat. Aktualizujte AnkiDroid nebo ponižte databázi, abyste ji otevřeli\n\nPodporovaná verze: %1$d\nVerze databáze: %2$d\n\nNásledující možnosti obnovení přepíšou vaši současnou kolekci snad kompatibilní verzí databáze: diff --git a/AnkiDroid/src/main/res/values-da/03-dialogs.xml b/AnkiDroid/src/main/res/values-da/03-dialogs.xml index f73fdabf8e17..8f7c9547cb58 100644 --- a/AnkiDroid/src/main/res/values-da/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-da/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-de/03-dialogs.xml b/AnkiDroid/src/main/res/values-de/03-dialogs.xml index d9709066e4cb..c307612e6849 100644 --- a/AnkiDroid/src/main/res/values-de/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-de/03-dialogs.xml @@ -70,7 +70,7 @@ Verlassen Weiter Wird verarbeitet … - Allow + Erlauben Erstellen Löschen @@ -117,7 +117,7 @@ Datenbank gesperrt - Die AnkiDroid-Datenbank wird von einer anderen Anwendung verwendet. Bitte schließen Sie die andere Anwendung und öffnen Sie AnkiDroid erneut. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Inkompatible Datenbankversion Die Datenbank ist eine fortgeschrittenere Version, als diese Version von AnkiDroid verwenden kann. Aktualisieren Sie AnkiDroid oder downgraden Sie die Datenbank um sie zu öffnen\n\nUnterstützte Version: %1$d\nDatenbankversion: %2$d\n\nDie folgenden Wiederherstellungsmöglichkeiten überschreiben Ihre aktuelle Sammlung, möglicherweise mit einer kompatiblen Datenbankversion: @@ -260,10 +260,10 @@ Der Name der Karten Vorlage%s Der Name des Notiztypen%s - From - To + Von + Zu - Profile name - Profile name can’t be empty - Profile name must be %1$d characters or fewer + Profilname + Der Profilname darf nicht leer sein + Der Profilname darf nur maximal %1$d Zeichen lang sein diff --git a/AnkiDroid/src/main/res/values-de/07-cardbrowser.xml b/AnkiDroid/src/main/res/values-de/07-cardbrowser.xml index d3040eb38522..012193f2a3fd 100644 --- a/AnkiDroid/src/main/res/values-de/07-cardbrowser.xml +++ b/AnkiDroid/src/main/res/values-de/07-cardbrowser.xml @@ -49,7 +49,7 @@ Name bereits vorhanden Suche wurde gespeichert Suche gelöscht - Keine notiz zum bearbeiten + Keine Notiz zum Bearbeiten »%1$s« löschen? Anzeigereihenfolge ändern Suchen @@ -78,7 +78,7 @@ %d Karten gelöscht - Schlagwörter-dialog bearbeiten + Schlagwörter-Dialog bearbeiten Bestellungsdialog anzeigen Spalten @@ -93,15 +93,15 @@ Mehr Anzeigen Weniger Anzeigen - No sorting - Faster - Sort by %1$s - A–Z - Z–A - Low to high - High to low - Oldest first - Newest first + Keine Sortierung + Schneller + Sortieren nach %1$s + A-Z + Z-A + Niedrig bis hoch + Hoch bis niedrig + Ältestes zuerst + Neuestes zuerst A @@ -110,10 +110,10 @@ 9 - Ascending - Descending - Selected - Unavailable - Card content can\'t be sorted. Use Sort Field - Only available in cards mode + Aufsteigend + Absteigend + Ausgewählt + Nicht verfügbar + Karteninhalt kann nicht sortiert werden. Nutze das Sortierungfeld + Nur verfügbar im Kartenmodus diff --git a/AnkiDroid/src/main/res/values-el/03-dialogs.xml b/AnkiDroid/src/main/res/values-el/03-dialogs.xml index c3e1fc4b23a2..3fa5025d5d96 100644 --- a/AnkiDroid/src/main/res/values-el/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-el/03-dialogs.xml @@ -117,7 +117,7 @@ Βάση Δεδομένων Κλειδωμένη - Η βάση δεδομένων του AnkiDroid χρησιμοποιείται από κάποια άλλη εφαρμογή. Κλείστε την άλλη εφαρμογή και στη συνέχεια, ανοίξτε ξανά το AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Ασύμβατη έκδοση βάσης δεδομένων. Η βάση δεδομένων είναι μια πιο προηγμένη έκδοση από αυτή με την οποία το AnkiDroid μπορεί να λειτουργήσει. Αναβάθμιση του AnkiDroid ή υποβάθμιση της βάσης δεδομένων για να την ανοίξετε\n\nΥποστηριζόμενη έκδοση: %1$d\nΈκδοση Βάσης Δεδομένων: %2$d\n\nΟι παρακάτω επιλογές επαναφοράς θα αντικαταστήσουν την τρέχουσα συλλογή σας, ενδεχομένως με συμβατή έκδοση βάσης δεδομένων: diff --git a/AnkiDroid/src/main/res/values-eo/03-dialogs.xml b/AnkiDroid/src/main/res/values-eo/03-dialogs.xml index 3b2a825c30f8..d965aa255fab 100644 --- a/AnkiDroid/src/main/res/values-eo/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-eo/03-dialogs.xml @@ -117,7 +117,7 @@ Datumbazo ŝlosita - La datumbazo de AnkiDroid estas uzata de alia aplikaĵo. Bonvolu fermi la alian aplikaĵon kaj remalfermi AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Nekongrua versio de datumbazo La datumbazo estas en pli nova versio ol tiu subtenata de tiu ĉi versio de AnkiDroid. Aktualigu la aplikaĵon AnkiDroid aŭ malpromociu la datumbazon por malfermi ĝin.\n\nSubtenata versio: %1$d\nVersio de datumbazo:%2$d\n\nLa jenaj restarigaj elektebloj ebligos al vi superskribi vian nunan kolekton, probable per kongrua versio de datumbazo: diff --git a/AnkiDroid/src/main/res/values-es-rAR/03-dialogs.xml b/AnkiDroid/src/main/res/values-es-rAR/03-dialogs.xml index b7c54cc15b9e..1a39d225549e 100644 --- a/AnkiDroid/src/main/res/values-es-rAR/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-es-rAR/03-dialogs.xml @@ -117,7 +117,7 @@ Base de datos bloqueada - La base de datos de AnkiDroid está en uso por otra aplicación. Por favor, cierre la otra aplicación y vuelva a abrir AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versión de base de datos incompatible La base de datos es una versión más avanzada con la que esta versión de AnkiDroid puede trabajar. Actualice AnkiDroid o degrade la base de datos para abrirla\n\nVersión soportada: %1$d\nVersión de la base de datos: %2$d\n\nLas siguientes opciones de restauración sobrescribirán su colección actual, posiblemente con una versión compatible de la base de datos: diff --git a/AnkiDroid/src/main/res/values-es-rES/03-dialogs.xml b/AnkiDroid/src/main/res/values-es-rES/03-dialogs.xml index 7b6c559485cd..d56b191c7ef4 100644 --- a/AnkiDroid/src/main/res/values-es-rES/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-es-rES/03-dialogs.xml @@ -117,7 +117,7 @@ Base de datos bloqueada - La base de datos de AnkiDroid está en uso por otra aplicación. Por favor, cierre la otra aplicación y vuelva a abrir AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versión de base de datos incompatible La base de datos es una versión más avanzada con la que esta versión de AnkiDroid puede trabajar. Actualice AnkiDroid o degrade la base de datos para abrirla\n\nVersión soportada: %1$d\nVersión de la base de datos: %2$d\n\nLas siguientes opciones de restauración sobrescribirán su colección actual, posiblemente con una versión compatible de la base de datos: diff --git a/AnkiDroid/src/main/res/values-et/03-dialogs.xml b/AnkiDroid/src/main/res/values-et/03-dialogs.xml index f0ff89501454..a936b9459ecd 100644 --- a/AnkiDroid/src/main/res/values-et/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-et/03-dialogs.xml @@ -117,7 +117,7 @@ Andmebaas on lukustatud - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Mitteühilduvad andmebaasi versioonid The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-eu/03-dialogs.xml b/AnkiDroid/src/main/res/values-eu/03-dialogs.xml index ebe92f5007d6..034b9dc782d2 100644 --- a/AnkiDroid/src/main/res/values-eu/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-eu/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-fa/03-dialogs.xml b/AnkiDroid/src/main/res/values-fa/03-dialogs.xml index cfa7533c3d7c..7b981f689358 100644 --- a/AnkiDroid/src/main/res/values-fa/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-fa/03-dialogs.xml @@ -117,7 +117,7 @@ دیتابیس قفل است - نرم‌افزاری دیگر در حال استفاده از دیتابیس آنکی‌دروید است. لطفاً نرم افزار دیگر را ببندید سپس آنکی‌دروید را باز کنید. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. نسخه پایگاه داده ناسازگار است نسخه ی پایگاه داده پیشرفته‌تر از نسخه ای است که AnkiDroid توانایی کار کردن با آن را دارد. یا AnkiDroid را ارتقاء دهید یا درجه پایگاه داده را پایین آورید\n\nنسخه های همایت شده: %1$d\nنسخه پایگاه داده: %2$d\n\nگزینه های بازگرداندن زیر مجموعه شما را بازنویسی میکنند، ممکن تر با نسخه پایگاه داده سازگار: diff --git a/AnkiDroid/src/main/res/values-fi/03-dialogs.xml b/AnkiDroid/src/main/res/values-fi/03-dialogs.xml index bc3d1a1d4b82..637e05523790 100644 --- a/AnkiDroid/src/main/res/values-fi/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-fi/03-dialogs.xml @@ -117,7 +117,7 @@ Tietokanta lukittu - AnkiDroidin tietokanta on toisen sovelluksen käytössä. Sulje toinen sovellus ja avaa AnkiDroid uudelleen. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Yhteensopimaton tietokannan versio Tietokanta on edistyneempi versio kuin mitä tämä AnkiDroid-versio voi käyttää. Päivitä AnkiDroid tai siirry vanhempaan tietokantaversioon avataksesi sen\n\nTuettu versio: %1$d\nTietokannan versio: %2$d\n\n\nSeuraavat palautusvaihtoehdot korvaavat nykyisen kokoelmasi, mahdollisesti yhteensopivalla tietokannan versiolla: diff --git a/AnkiDroid/src/main/res/values-fil/03-dialogs.xml b/AnkiDroid/src/main/res/values-fil/03-dialogs.xml index 5887e452c0a3..adb52a3d7595 100644 --- a/AnkiDroid/src/main/res/values-fil/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-fil/03-dialogs.xml @@ -117,7 +117,7 @@ Nakakandado ang Database - Ginagamit ng ibang aplikasyon ang database ng AnkiDroid. Mangyaring isara ang ibang aplikasyon at buksan muli ang AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Hindi Angkop Ang Bersyon ng Database Mas abante ang database na ito kumpara sa bersyong kayang gamitin ng AnkiDroid. I-upgrade o i-downgrade ang database ng AnkiDroid upang mabuksan ito\n\nBersyon na suportado: %1$d\nBersyon ng database: %2$d\n\nAng sumusunod na mga opsyon sa pagbalik ay posibleng sapawan ang kasalukuyan mong koleksyon ng isang angkop na bersyon ng database. diff --git a/AnkiDroid/src/main/res/values-fr/03-dialogs.xml b/AnkiDroid/src/main/res/values-fr/03-dialogs.xml index ad3cfcf44a95..0d1bdf60a65b 100644 --- a/AnkiDroid/src/main/res/values-fr/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-fr/03-dialogs.xml @@ -117,7 +117,7 @@ Base de données verrouillée - La base de données AnkiDroid est utilisée par une autre application. Veuillez fermer l\'autre application puis rouvrir AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Version de la base de données incompatible La base de données est une version plus avancée que la version avec laquelle AnkiDroid peut fonctionner. Mettre à jour AnkiDroid ou rétrograder la base de données pour l\'ouvrir\n\nVersion prise en charge : %1$d\nVersion de la base de données : %2$d\n\nLes options de restauration suivantes écraseront votre collection actuelle, éventuellement avec une version compatible de la base de données : diff --git a/AnkiDroid/src/main/res/values-fy/03-dialogs.xml b/AnkiDroid/src/main/res/values-fy/03-dialogs.xml index 1b0ee6d99d6f..daf8bf9bcf69 100644 --- a/AnkiDroid/src/main/res/values-fy/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-fy/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ga/03-dialogs.xml b/AnkiDroid/src/main/res/values-ga/03-dialogs.xml index 20cad664aced..a47945b1b9b5 100644 --- a/AnkiDroid/src/main/res/values-ga/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ga/03-dialogs.xml @@ -126,7 +126,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-gl/03-dialogs.xml b/AnkiDroid/src/main/res/values-gl/03-dialogs.xml index 8852bc95a179..38a91cbcca61 100644 --- a/AnkiDroid/src/main/res/values-gl/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-gl/03-dialogs.xml @@ -117,7 +117,7 @@ Base de datos bloqueada - Outra aplicación está utilizando a base de datos do AnkiDroid. Pecha a outra aplicación e reabre o AnkiDroid + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versión da base de datos incompatíbel A base de datos é dunha versión máis avanzada da que esta versión do AnkiDroid pode manexar. Actualiza o AnkiDroid ou reverte a versión da base de datos para abrila.\n\nVersión compatíbel: %1$d\nVersión da base de datos: %2$d\n\nAs seguintes opcións de restauración sobrescribirán a túa colección actual, posibelmente cunha versión compatible da base de datos: diff --git a/AnkiDroid/src/main/res/values-got/03-dialogs.xml b/AnkiDroid/src/main/res/values-got/03-dialogs.xml index ad4ec4f0a7c2..98f0bb73f470 100644 --- a/AnkiDroid/src/main/res/values-got/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-got/03-dialogs.xml @@ -117,7 +117,7 @@ 𐌳𐌰𐍄𐌰𐌷𐌿𐌶𐌳 𐌻𐌿𐌺𐌰𐌽 - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. 𐌿𐌽𐌲𐌰𐍄𐌹𐌻 𐌳𐌰𐍄𐌰𐌷𐌿𐌶𐌳𐌹𐍃 𐌿𐍃𐌼𐌴𐍂𐌹 The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-gu/03-dialogs.xml b/AnkiDroid/src/main/res/values-gu/03-dialogs.xml index 01a5720cc294..cd21780d9d35 100644 --- a/AnkiDroid/src/main/res/values-gu/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-gu/03-dialogs.xml @@ -117,7 +117,7 @@ ડેટાબેઝ લોક - AnkiDroid ડેટાબેઝનો ઉપયોગ અન્ય એપ્લિકેશનો દ્વારા થાય છે. કૃપા કરીને બીજી એપ્લિકેશન બંધ કરો પછી AnkiDroid ફરીથી ખોલો. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. અસંગત ડેટાબેઝ સંસ્કરણ ડેટાબેઝ એ AnkiDroid ના આ વર્ઝન સાથે કામ કરી શકે છે તેના કરતાં વધુ અદ્યતન સંસ્કરણ છે. AnkiDroid ને અપગ્રેડ કરો અથવા તેને ખોલવા માટે ડેટાબેઝને ડાઉનગ્રેડ કરો\n\nઆધારિત સંસ્કરણ: %1$d\nડેટાબેઝ સંસ્કરણ: %2$d\n\nનીચેના પુનઃસ્થાપિત વિકલ્પો તમારા વર્તમાન સંગ્રહને ઓવરરાઈટ કરશે, સંભવતઃ સુસંગત ડેટાબેઝ સંસ્કરણ સાથે: diff --git a/AnkiDroid/src/main/res/values-heb/03-dialogs.xml b/AnkiDroid/src/main/res/values-heb/03-dialogs.xml index 688ee38d9593..f27b75892851 100644 --- a/AnkiDroid/src/main/res/values-heb/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-heb/03-dialogs.xml @@ -121,7 +121,7 @@ מסד הנתונים ננעל - מסד הנתונים של AnkiDroid נמצא בשימוש על ידי יישום אחר. אנא סגור את היישום השני ואז פתח מחדש את AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. גרסת מסד נתונים לא תואמת מסד הנתונים הוא גרסה מתקדמת יותר ממה שגרסה זו של AnkiDroid יכולה לעבוד איתה. שדרג את AnkiDroid או שדרג לאחור את מסד הנתונים כדי לפתוח אותו\n\nגרסה נתמכת: %1$d\nגרסת מסד נתונים: %2$d\n\nאפשרויות השחזור הבאות יחליפו את האוסף הנוכחי שלך, אולי עם גרסת מסד נתונים תואמת: diff --git a/AnkiDroid/src/main/res/values-hi/03-dialogs.xml b/AnkiDroid/src/main/res/values-hi/03-dialogs.xml index e4d7d39a3752..0938fb1e6673 100644 --- a/AnkiDroid/src/main/res/values-hi/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-hi/03-dialogs.xml @@ -117,7 +117,7 @@ डेटाबेस लॉक है - AnkiDroid डेटाबेस किसी अन्य एप्लिकेशन द्वारा उपयोग में है। कृपया अन्य एप्लिकेशन को बंद करें और फिर AnkiDroid को फिर से खोलें। + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. असंगत डेटाबेस संस्करण डेटाबेस AnkiDroid के इस संस्करण की तुलना में अधिक उन्नत संस्करण है, जिसके साथ काम कर सकते हैं। AnkiDroid को अपग्रेड करें या डेटाबेस को डाउनग्रेड करने के लिए इसे खोलें \n\n अद्यतन संस्करण: %1$d\n Database संस्करण:%2$d\n\n निम्नलिखित पुनर्स्थापना विकल्प आपके वर्तमान संग्रह को अधिलेखित करेंगे, संभवतः एक संगत डेटाबेस संस्करण के साथ: diff --git a/AnkiDroid/src/main/res/values-hr/03-dialogs.xml b/AnkiDroid/src/main/res/values-hr/03-dialogs.xml index 6aeb066edf66..ba42b60093ae 100644 --- a/AnkiDroid/src/main/res/values-hr/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-hr/03-dialogs.xml @@ -120,7 +120,7 @@ Baza podataka zaključana - Druga aplikacija koristi AnkiDroid bazu podataka. Zatvorite drugu aplikaciju ze zatim ponovno otvorite AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Nekompatibilna verzija baze podataka Verzija baze podataka je naprednija od one s kojom trenutna verzija AnkiDroida može rukovati. Ažurirajte AnkiDroid ili smanjite verziju baze podataka kako biste je otvorili\n\nPodržana verzija:%1$d\nVerzija baze podataka: %2$d\n\nSljedeće postavke za obnovu će pregaziti vašu trenutnu kolekciju, potencijalno s kompatibilnom verzijom baze podatka: diff --git a/AnkiDroid/src/main/res/values-hu/03-dialogs.xml b/AnkiDroid/src/main/res/values-hu/03-dialogs.xml index e8be4d4e90c7..f09355f1838a 100644 --- a/AnkiDroid/src/main/res/values-hu/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-hu/03-dialogs.xml @@ -117,7 +117,7 @@ Adatbázis zárolva - Az AnkiDroid adatbázist egy másik alkalmazás használja. Kérjük zárja be ezt az alkalmazást, majd nyissa meg újra az AnkiDroidot. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Nem megfelelő adatbázis verzió Az adatbázis verziója fejlettebb, mint amit az AnkiDroid ezen verziója támogat. Frissítsd az AnkiDroid-ot vagy válts egy régebbi adatbázis verzióra.\n\nTámogatott verzió: %1$d\nAdatbázis verziója: %2$d\n\nA következő visszaállítási opciók felülírják a jelenlegi gyűjteményt, lehetőleg egy támogatott adatbázis verzióra: diff --git a/AnkiDroid/src/main/res/values-hy/03-dialogs.xml b/AnkiDroid/src/main/res/values-hy/03-dialogs.xml index dcea7f7fed65..b413dc3c9561 100644 --- a/AnkiDroid/src/main/res/values-hy/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-hy/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ind/03-dialogs.xml b/AnkiDroid/src/main/res/values-ind/03-dialogs.xml index 8e97c1770884..68210d91c586 100644 --- a/AnkiDroid/src/main/res/values-ind/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ind/03-dialogs.xml @@ -114,7 +114,7 @@ Basisdata terkunci - Basisdata AnkiDroid sedang dipakai aplikasi lain. Harap tutup aplikasi yang lain lalu buka AnkiDroid lagi. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versi Database tidak sesuai Versi basis data lebih unggul dari versi basis data yang AnkiDroid gunakan. Perbarui AnkiDroid atau ganti ke versi lebih lama untuk membukanya\n\nVersi yang didukung: %1$d\nVersi basis data: %2$d\n\nOpsi pengembalian berikut ini akan mengganti koleksi yang Anda miliki sekarang, kemungkinan dengan koleksi yang sesuai dengan versi basis data: diff --git a/AnkiDroid/src/main/res/values-it/03-dialogs.xml b/AnkiDroid/src/main/res/values-it/03-dialogs.xml index cc2b3c4d5cb5..64165e6bc7eb 100644 --- a/AnkiDroid/src/main/res/values-it/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-it/03-dialogs.xml @@ -117,7 +117,7 @@ Banca dati bloccata - La banca dati di AnkiDroid è in uso da un\'altra applicazione. Chiudi l\'altra applicazione e riapri AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versione del Database Incompatibile Il database è una versione più avanzata di questa con cui AnkiDroid può funzionare. Aggiorna AnkiDroid o esegui il downgrade del database per aprirlo\n\nVersione supportata: %1$d\nVersione del database: %2$d\n\nLe seguenti opzioni di ripristino sovrascriveranno la tua raccolta corrente, possibilmente con una versione compatibile del database: diff --git a/AnkiDroid/src/main/res/values-iw/03-dialogs.xml b/AnkiDroid/src/main/res/values-iw/03-dialogs.xml index 688ee38d9593..f27b75892851 100644 --- a/AnkiDroid/src/main/res/values-iw/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-iw/03-dialogs.xml @@ -121,7 +121,7 @@ מסד הנתונים ננעל - מסד הנתונים של AnkiDroid נמצא בשימוש על ידי יישום אחר. אנא סגור את היישום השני ואז פתח מחדש את AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. גרסת מסד נתונים לא תואמת מסד הנתונים הוא גרסה מתקדמת יותר ממה שגרסה זו של AnkiDroid יכולה לעבוד איתה. שדרג את AnkiDroid או שדרג לאחור את מסד הנתונים כדי לפתוח אותו\n\nגרסה נתמכת: %1$d\nגרסת מסד נתונים: %2$d\n\nאפשרויות השחזור הבאות יחליפו את האוסף הנוכחי שלך, אולי עם גרסת מסד נתונים תואמת: diff --git a/AnkiDroid/src/main/res/values-ja/02-strings.xml b/AnkiDroid/src/main/res/values-ja/02-strings.xml index 471be7134a5e..71be8b7e75e0 100644 --- a/AnkiDroid/src/main/res/values-ja/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ja/02-strings.xml @@ -73,7 +73,7 @@ WebViewはシステムによって終了されました。これは通常、アプリケーションがメモリ不足時に発生します。多くの場合、データ量の多いフォントやメディアが原因です。 WebViewレンダラーがクラッシュしました。原因: %s Fatal error: WebView renderer crashed. Cause: %s - System WebView rendering failure + システムのWebViewのレンダリングエラー AndroidシステムのWebViewがカード \'%1$s\' のレンダリングに失敗しました。\n %2$s 左にスライドでキャンセル フラグ @@ -126,7 +126,7 @@ Markdown形式でコピー %1$d / %2$d 名前を変更 - チェック + 検査 タイピングして答えを入力: 不明なフィールド %s デッキを削除しています… AnkiDroidを評価 diff --git a/AnkiDroid/src/main/res/values-ja/03-dialogs.xml b/AnkiDroid/src/main/res/values-ja/03-dialogs.xml index 7e6e8ba75faa..471153c4c3a9 100644 --- a/AnkiDroid/src/main/res/values-ja/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ja/03-dialogs.xml @@ -114,7 +114,7 @@ データベースがロックされています - AnkiDroidデータベースを別のアプリが使用中です。該当するアプリを終了してAnkiDroidを再起動してください。 + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. 互換性のないバージョンのデータベース このデータベースは、このAnkiDroidでは使用できません。 これを使用するには、AnkiDroidをアップグレードするか、データベースをダウングレードしてください。\n\nこのAnkiDroidで開ける最大バージョン: %1$d\nこのデータベースのバージョン: %2$d\n\n次の復元オプションは、現在のコレクションを、このAnkiDroidで使用できる可能性が高いデータベースで上書きします。 diff --git a/AnkiDroid/src/main/res/values-ja/10-preferences.xml b/AnkiDroid/src/main/res/values-ja/10-preferences.xml index bf04846c3b7f..dcda4fff235c 100644 --- a/AnkiDroid/src/main/res/values-ja/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ja/10-preferences.xml @@ -224,15 +224,15 @@ 学習リマインダー コレクション全体 特定のデッキ - An error occurred while loading your review reminders, corrupted reminders have been deleted. Details: + 学習リマインダーの読み込み中にエラーが発生しました。破損したリマインダーは削除されました。詳細: Schedule reminders リマインダーを追加 学習のリマインダーがありません トラブルシューティング アプリの通知設定 - List of scheduled review reminders - Deck not found + スケジュール済みの学習リマインダーの一覧 + デッキが見つかりません リマインダーが機能していません 設定変更 新しい学習リマインダーを追加しました @@ -241,16 +241,16 @@ 学習リマインダーを追加 学習リマインダーを編集 - 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. + このリマインダーを削除しますか? + この操作は元に戻せません。 リマインダーが機能していません。 リマインダーが正常に動作しない可能性があります。 diff --git a/AnkiDroid/src/main/res/values-ka/03-dialogs.xml b/AnkiDroid/src/main/res/values-ka/03-dialogs.xml index 08a89a5f4294..2040461a8a02 100644 --- a/AnkiDroid/src/main/res/values-ka/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ka/03-dialogs.xml @@ -117,7 +117,7 @@ მონაცემთა ბაზა ჩაკეტილია - სხვა აპლიკაცია იყენებს AnkiDroid-ის მონაცემთა ბაზას. გთხოვთ დახუროთ ეგ აპლიკაცია და ხელახლა გახსნათ AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. მონაცემთა ბაზის შეუთავსებელი ვერსია The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-kk/03-dialogs.xml b/AnkiDroid/src/main/res/values-kk/03-dialogs.xml index a9e280184cec..62b1138961b6 100644 --- a/AnkiDroid/src/main/res/values-kk/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-kk/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-km/03-dialogs.xml b/AnkiDroid/src/main/res/values-km/03-dialogs.xml index c3b49cad2704..31c62460198d 100644 --- a/AnkiDroid/src/main/res/values-km/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-km/03-dialogs.xml @@ -114,7 +114,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-kn/03-dialogs.xml b/AnkiDroid/src/main/res/values-kn/03-dialogs.xml index cf1574a31842..82b3131ba2ff 100644 --- a/AnkiDroid/src/main/res/values-kn/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-kn/03-dialogs.xml @@ -117,7 +117,7 @@ ಡೇಟಾಬೇಸ್ ಲಾಕ್ ಆಗಿದೆ - AnkiDroid ಡೇಟಾಬೇಸ್ ಮತ್ತೊಂದು ಅಪ್ಲಿಕೇಶನ್‌ನಿಂದ ಬಳಕೆಯಲ್ಲಿದೆ. ದಯವಿಟ್ಟು ಇತರ ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಮುಚ್ಚಿ ಮತ್ತು ನಂತರ AnkiDroid ಅನ್ನು ಪುನಃ ತೆರೆಯಿರಿ. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. ಹೊಂದಾಣಿಕೆಯಾಗದ ಡೇಟಾಬೇಸ್ ಆವೃತ್ತಿ AnkiDroid ನ ಈ ಆವೃತ್ತಿಯು ಕೆಲಸ ಮಾಡುವುದಕ್ಕಿಂತ ಡೇಟಾಬೇಸ್ ಹೆಚ್ಚು ಸುಧಾರಿತ ಆವೃತ್ತಿಯಾಗಿದೆ. AnkiDroid ಅನ್ನು ಅಪ್‌ಗ್ರೇಡ್ ಮಾಡಿ ಅಥವಾ ಅದನ್ನು ತೆರೆಯಲು ಡೇಟಾಬೇಸ್ ಅನ್ನು ಡೌನ್‌ಗ್ರೇಡ್ ಮಾಡಿ\n\nಬೆಂಬಲಿತ ಆವೃತ್ತಿ: %1$d\nಡೇಟಾಬೇಸ್ ಆವೃತ್ತಿ: %2$d\n\nಈ ಕೆಳಗಿನ ಮರುಸ್ಥಾಪನೆ ಆಯ್ಕೆಗಳು ನಿಮ್ಮ ಪ್ರಸ್ತುತ ಸಂಗ್ರಹಣೆಯನ್ನು ಓವರ್‌ರೈಟ್ ಮಾಡುತ್ತದೆ, ಬಹುಶಃ ಹೊಂದಾಣಿಕೆಯ ಡೇಟಾಬೇಸ್ ಆವೃತ್ತಿಯೊಂದಿಗೆ: diff --git a/AnkiDroid/src/main/res/values-ko/03-dialogs.xml b/AnkiDroid/src/main/res/values-ko/03-dialogs.xml index 6725a8eec274..8d31b75095a0 100644 --- a/AnkiDroid/src/main/res/values-ko/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ko/03-dialogs.xml @@ -114,7 +114,7 @@ 데이터베이스 잠김 - 다른 앱에 의해 AnkiDroid의 데이터베이스가 사용 중입니다. 다른 앱을 닫고 AnkiDroid를 재시작하세요. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ku/03-dialogs.xml b/AnkiDroid/src/main/res/values-ku/03-dialogs.xml index a391163131d6..3444c826968a 100644 --- a/AnkiDroid/src/main/res/values-ku/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ku/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ky/03-dialogs.xml b/AnkiDroid/src/main/res/values-ky/03-dialogs.xml index e37c55819c38..fd5a915aee85 100644 --- a/AnkiDroid/src/main/res/values-ky/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ky/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-land/integers.xml b/AnkiDroid/src/main/res/values-land/integers.xml new file mode 100644 index 000000000000..5849eca4305c --- /dev/null +++ b/AnkiDroid/src/main/res/values-land/integers.xml @@ -0,0 +1,6 @@ + + + + + 1 + diff --git a/AnkiDroid/src/main/res/values-lt/03-dialogs.xml b/AnkiDroid/src/main/res/values-lt/03-dialogs.xml index 1912659f1954..bf42f8d2bf28 100644 --- a/AnkiDroid/src/main/res/values-lt/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-lt/03-dialogs.xml @@ -123,7 +123,7 @@ Duomenų bazė užrakinta - \"AnkiDroid\" duomenų bazę naudoja kita programa. Uždarykite kitą programą ir vėl atidarykite \"AnkiDroid\". + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Nesuderinama duomenų bazės versija Duomenų bazė yra pažangesnė versija, nei su kuria gali dirbti ši \"AnkiDroid\" versija. Atnaujinkite \"AnkiDroid\" arba sumažinkite duomenų bazės lygį, kad ją atidarytumėte\n\nPalaikoma versija: %1$d\nDomenų bazės versija: %2$d\n\nNurodytos atkūrimo parinktys perrašys jūsų dabartinę kolekciją, galbūt su suderinama duomenų bazės versija: diff --git a/AnkiDroid/src/main/res/values-lv/03-dialogs.xml b/AnkiDroid/src/main/res/values-lv/03-dialogs.xml index a2bea7b83aff..4fec9c590be9 100644 --- a/AnkiDroid/src/main/res/values-lv/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-lv/03-dialogs.xml @@ -120,7 +120,7 @@ Datubāze slēgta - Cita lietotne izmanto AnkiDroid datubāzi. Lūgums aizvērt to lietotni un no jauna atvērt AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Nesaderīga datubāzes versija Datubāzei ir jaunāka versija nekā tā, ar ko šī AnkiDroid versija var darboties. Jāuzlabo AnkiDroid vai jāpazemina databāze, lai atvērtu to\n\nAtbalstītā versija: %1$d\nDatubāzes versija: %2$d\n\nŠīs atjaunošanas iespējas aizstās pašreizējo krājumu, iespējams ar saderīgu datubāzes versiju: diff --git a/AnkiDroid/src/main/res/values-mk/03-dialogs.xml b/AnkiDroid/src/main/res/values-mk/03-dialogs.xml index 491e7637edb3..33ac9c2d96bd 100644 --- a/AnkiDroid/src/main/res/values-mk/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-mk/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ml/03-dialogs.xml b/AnkiDroid/src/main/res/values-ml/03-dialogs.xml index 4eec6da4bb91..70cebd3b268c 100644 --- a/AnkiDroid/src/main/res/values-ml/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ml/03-dialogs.xml @@ -117,7 +117,7 @@ ഡാറ്റാബേസ് ലോക്കുചെയ്തു - മറ്റൊരു ആപ്ലിക്കേഷൻ AnkiDroid ഡാറ്റാബേസ് ഉപയോഗത്തിലാണ്. ദയവായി മറ്റ് ആപ്ലിക്കേഷൻ അടച്ച് അങ്കിഡ്രോയിഡ് വീണ്ടും തുറക്കുക. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. അനുയോജ്യമല്ലാത്ത ഡാറ്റാബേസ് പതിപ്പ് The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-mn/03-dialogs.xml b/AnkiDroid/src/main/res/values-mn/03-dialogs.xml index 09d2f5c37d8f..0ed592be4794 100644 --- a/AnkiDroid/src/main/res/values-mn/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-mn/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-mr/03-dialogs.xml b/AnkiDroid/src/main/res/values-mr/03-dialogs.xml index 2774a2700ea4..9a774fd05129 100644 --- a/AnkiDroid/src/main/res/values-mr/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-mr/03-dialogs.xml @@ -117,7 +117,7 @@ डेटाबेस लॉक केले - AnkiDroid डेटाबेस दुसर्‍या अनुप्रयोगाद्वारे वापरात आहे. कृपया अन्य अनुप्रयोग बंद करा आणि नंतर पुन्हा एंकीड्रॉइड उघडा. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. विसंगत डेटाबेस आवृत्ती डेटाबेस ही अँकीड्रॉइडच्या आवृत्तीसह कार्य करू शकते त्यापेक्षा अधिक प्रगत आवृत्ती आहे. AnkiDroid श्रेणीसुधारित करा किंवा ते उघडण्यासाठी डेटाबेस डाउनग्रेड करा \n\n समर्थित आवृत्ती:%1$d \ n डेटाबेस आवृत्ती:%2$d \n\n खालील पुनर्संचयित पर्याय आपल्या वर्तमान संग्रहास अधिलिखित करतील, शक्यतो सुसंगत डेटाबेस आवृत्तीसह: diff --git a/AnkiDroid/src/main/res/values-ms/03-dialogs.xml b/AnkiDroid/src/main/res/values-ms/03-dialogs.xml index 235e054d23a9..7d2e361f5bc0 100644 --- a/AnkiDroid/src/main/res/values-ms/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ms/03-dialogs.xml @@ -114,7 +114,7 @@ Pangkalan Data Dikunci - Pangkalan data AnkiDroid sedang digunakan oleh aplikasi lain. Sila tutup aplikasi tersebut kemudian buka semula AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versi Pangkalan Data Tidak Serasi Pangkalan data memiliki versi lebih lanjut daripada versi AnkiDoird yang boleh digunakannya. Naik taraf AnkiDroid atau turun taraf pangkalan data untuk membukanya.\n\nVersi disokong: %1$d\nVersi pangkalan data: %2$d\n\nPilihan pemulihan berikut akan tulis ganti koleksi anda, mungkin dengan versi pangkalan data yang serasi: diff --git a/AnkiDroid/src/main/res/values-my/03-dialogs.xml b/AnkiDroid/src/main/res/values-my/03-dialogs.xml index 4c8ff155e87c..87d697dba704 100644 --- a/AnkiDroid/src/main/res/values-my/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-my/03-dialogs.xml @@ -114,7 +114,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-nl/03-dialogs.xml b/AnkiDroid/src/main/res/values-nl/03-dialogs.xml index 9bc2dacb5d87..88302521e4d5 100644 --- a/AnkiDroid/src/main/res/values-nl/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-nl/03-dialogs.xml @@ -117,7 +117,7 @@ Database vergrendeld - De AnkiDroid-database is in gebruik door een andere applicatie. Sluit de andere applicatie en open vervolgens AnkiDroid opnieuw. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatibele databaseversie De database is een meer geavanceerde versie dan deze versie van AnkiDroid mee kan werken. Upgrade AnkiDroid of downgrade de database om het te openen\n\nOndersteunde versie: %1$d\nDatabaseversie: %2$d\n\nDe volgende herstelopties zullen uw huidige collectie overschrijven, eventueel met een compatibele databaseversie: diff --git a/AnkiDroid/src/main/res/values-nn/03-dialogs.xml b/AnkiDroid/src/main/res/values-nn/03-dialogs.xml index e4b18c40241e..56f045dbc1ad 100644 --- a/AnkiDroid/src/main/res/values-nn/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-nn/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-no/03-dialogs.xml b/AnkiDroid/src/main/res/values-no/03-dialogs.xml index 75f508953dd7..dd422af2d52e 100644 --- a/AnkiDroid/src/main/res/values-no/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-no/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-or/03-dialogs.xml b/AnkiDroid/src/main/res/values-or/03-dialogs.xml index a593ae697828..9d052bbe348d 100644 --- a/AnkiDroid/src/main/res/values-or/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-or/03-dialogs.xml @@ -117,7 +117,7 @@ ଡାଟାବେସ୍ ଲକ୍ ହୋଇଛି - AnkiDroid ଡାଟାବେସ୍ ଅନ୍ୟ ଏକ ଆପ୍ ଦ୍ୱାରା ଵ୍ୟଵହାର ହେଉଛି। ଦୟାକରି ଅନ୍ୟ ଆପ୍‌ଟିକୁ ବନ୍ଦ କରି AnkiDroid ଖୋଲନ୍ତୁ। + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. ଅସଙ୍ଗତ ଡାଟାବେସ୍ ସଂସ୍କରଣ AnkiDroidର ଏହି ସଂସ୍କରଣ ଡାଟାବେସ୍ ସହିତ କାମ କରିପାରିବନାହିଁ। ଏହାକୁ ଖୋଲିବା ପାଇଁ AnkiDroidକୁ ଅଦ୍ୟତନ କରନ୍ତୁ କିମ୍ବା ଡାଟାବେସ୍‌କୁ ଡାଉନଗ୍ରେଡ୍ କରନ୍ତୁ\n\nସମର୍ଥିତ ସଂସ୍କରଣ: %1$d\nଡାଟାବେସ୍ ସଂସ୍କରଣ: %2$d\n\nନିମ୍ନଲିଖିତ ପୁନରୁଦ୍ଧାର ଵିକଳ୍ପ ଆପଣଙ୍କର ସାମ୍ପ୍ରତିକ ସଂଗ୍ରହକୁ ନବଲିଖନ କରିବ, ସମ୍ଭବତଃ ଏକ ସୁସଙ୍ଗତ ଡାଟାବେସ୍ ସଂସ୍କରଣ ସହିତ: diff --git a/AnkiDroid/src/main/res/values-pa/03-dialogs.xml b/AnkiDroid/src/main/res/values-pa/03-dialogs.xml index e77ce3eb51ea..6b94a981cf38 100644 --- a/AnkiDroid/src/main/res/values-pa/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-pa/03-dialogs.xml @@ -117,7 +117,7 @@ ਡਾਟਾਬੇਸ ਲਾਕ ਕੀਤਾ - AnkiDroid ਡੇਟਾਬੇਸ ਕਿਸੇ ਹੋਰ ਐਪਲੀਕੇਸ਼ਨ ਦੁਆਰਾ ਵਰਤੋਂ ਵਿੱਚ ਹੈ। ਕਿਰਪਾ ਕਰਕੇ ਦੂਜੀ ਐਪਲੀਕੇਸ਼ਨ ਨੂੰ ਬੰਦ ਕਰੋ ਅਤੇ ਫਿਰ AnkiDroid ਨੂੰ ਦੁਬਾਰਾ ਖੋਲ੍ਹੋ। + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. ਅਸੰਗਤ ਡਾਟਾਬੇਸ ਸੰਸਕਰਣ The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-pl/03-dialogs.xml b/AnkiDroid/src/main/res/values-pl/03-dialogs.xml index 32059bf5fbe0..025cc0305145 100644 --- a/AnkiDroid/src/main/res/values-pl/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-pl/03-dialogs.xml @@ -123,7 +123,7 @@ Baza danych zablokowana - Baza danych AnkiDroid jest teraz używana przez inną aplikację. Zamknij drugą aplikację, a następnie ponownie otwórz AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Niekompatybilna wersja bazy danych Baza danych jest w wersji wyższejej niż najwyższa obsługiwana przez AnkiDroid. Zaktualizuj AnkiDroid lub przywróć poprzednią wersję bazy danych, by kontynuować. Obsługiwana wersja: %1$d\nwersja bazy danych: %2$d\nPoniższe opcje przywracania nadpiszą twoją obecną kolekcję, prawdopodobnie kompatybilną wersją diff --git a/AnkiDroid/src/main/res/values-pt-rBR/03-dialogs.xml b/AnkiDroid/src/main/res/values-pt-rBR/03-dialogs.xml index b2a6795f0c8c..4ee06fedc2f3 100644 --- a/AnkiDroid/src/main/res/values-pt-rBR/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-pt-rBR/03-dialogs.xml @@ -117,7 +117,7 @@ Banco de dados bloqueado - O banco de dados do AnkiDroid está em uso por outro aplicativo. Por favor, feche o outro aplicativo e reabra o AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versão do banco de dados incompatível O banco de dados é uma versão mais avançada do que esta versão do AnkiDroid pode trabalhar. Atualize o AnkiDroid ou faça o downgrade do banco de dados para abri-lo\n\nVersão Suportada: %1$d\nVersão do banco de dados: %2$d\n\nAs seguintes opções de restauração substituirão sua coleção atual, possivelmente com uma versão compatível do banco de dados: diff --git a/AnkiDroid/src/main/res/values-pt-rPT/03-dialogs.xml b/AnkiDroid/src/main/res/values-pt-rPT/03-dialogs.xml index 0ed89c9f6e03..ab7a3b137e6f 100644 --- a/AnkiDroid/src/main/res/values-pt-rPT/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-pt-rPT/03-dialogs.xml @@ -117,7 +117,7 @@ Base de Dados Bloqueada - A base de dados do AnkiDroid está a ser utilizada por outra aplicação. Por favor, feche a outra aplicação e reabra o AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versão de Base de Dados Incompatível A base de dados tem uma versão mais recente do que aquelas com que esta versão do AnkiDroid consegue trabalhar. Actualize o AnkiDroid ou converta a base de dados para uma versão mais antiga.\n\nVersões Suportadas: %1$d\nVersão da Base de Dados: %2$d\n\nAs seguintes opções de restauro vão substituir a actual colecção, provavelmente com uma versão de base de dados compatível: diff --git a/AnkiDroid/src/main/res/values-ro/03-dialogs.xml b/AnkiDroid/src/main/res/values-ro/03-dialogs.xml index fcc707949414..1a09ddc79ef2 100644 --- a/AnkiDroid/src/main/res/values-ro/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ro/03-dialogs.xml @@ -120,7 +120,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ru/03-dialogs.xml b/AnkiDroid/src/main/res/values-ru/03-dialogs.xml index c5b99c9451c5..6c6c0ac7c6b4 100644 --- a/AnkiDroid/src/main/res/values-ru/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ru/03-dialogs.xml @@ -124,7 +124,7 @@ База данных заблокирована - База данных AnkiDroid используется другим приложением. Закройте другое приложение, а затем снова откройте AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Несовместимая версия базы данных Эта база данных современнее той, с которой может работать эта версия AnkiDroid. Обновите AnkiDroid или понизите версию базы данных, чтобы открыть её\n\nПоддерживаемая версия: %1$d\nВерсия базы данных: %2$d\n\nСледующие параметры восстановления перезапишут вашу текущую коллекцию, возможно, с совместимой версией базы данных: diff --git a/AnkiDroid/src/main/res/values-sat/03-dialogs.xml b/AnkiDroid/src/main/res/values-sat/03-dialogs.xml index 02cf111f4799..42f236ede034 100644 --- a/AnkiDroid/src/main/res/values-sat/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sat/03-dialogs.xml @@ -117,7 +117,7 @@ ᱥᱟᱫᱽᱠᱷᱤᱭᱟᱹ ᱵᱟᱹᱭᱥᱟᱣ ᱞᱚᱠ ᱮᱱᱟ - AnkiDroid ᱥᱟᱫᱽᱠᱷᱤᱭᱟᱹ ᱵᱟᱹᱭᱥᱟᱣ ᱫᱚ ᱚᱞᱜᱟ ᱮᱯᱞᱤᱠᱮᱥᱚᱱ ᱠᱟᱢᱤ ᱨᱮ ᱟᱜᱩᱭᱮᱫᱟᱭ ᱾ ᱫᱚᱭᱟᱠᱟᱛᱮ AnkiDroid ᱵᱚᱸᱫ ᱠᱟᱛᱮ ᱯᱦᱮᱨ ᱥᱮ ᱠᱷᱩᱞᱟᱹᱭ ᱢᱮ ᱾ + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. ᱵᱟᱝ ᱴᱷᱤᱠ ᱵᱟᱴᱟᱵᱮᱥ ᱵᱷᱟᱹᱨᱥᱟᱹᱱ ᱰᱟᱴᱟᱵᱮᱥ ᱫᱚ ᱱᱚᱶᱟ AnkiDroid ᱵᱷᱟᱹᱥᱚᱱ ᱠᱷᱚᱱ ᱡᱟᱹᱛᱤ ᱮᱰᱵᱷᱟᱱᱥ ᱜᱮᱭᱟ ᱾ ᱠᱷᱚᱞᱟ ᱞᱟᱹᱜᱤᱫᱽ ᱰᱟᱴᱟᱵᱮᱥ ᱟᱹᱯᱜᱮᱰ ᱟᱨ ᱵᱟᱝ ᱰᱟᱩᱱᱜᱨᱮᱰ ᱢᱮ\n\n ᱥᱟᱹᱯᱚᱴ ᱵᱷᱟᱹᱨᱥᱚᱱᱺ %1$d\n ᱰᱟᱴᱟᱵᱮᱥ ᱵᱷᱟᱹᱥᱚᱱᱺ %2$d\n\n ᱱᱚᱶᱟ ᱠᱚ ᱨᱤᱥᱴᱚᱨ ᱚᱯᱥᱚᱱ ᱠᱚ ᱟᱢᱟᱜᱽ ᱱᱤᱛᱚᱜᱼᱟᱜ ᱛᱩᱢᱟᱹ ᱥᱟᱞᱟᱜᱽ ᱫᱩᱦᱲᱟᱹ ᱚᱞᱚᱜᱚᱜ ᱠᱚᱢᱯᱟᱴᱤᱵᱟᱹᱞ ᱰᱟᱴᱟᱵᱮᱥ ᱵᱷᱟᱹᱥᱚᱱ ᱥᱟᱞᱟᱜᱽ ᱛᱮᱺ diff --git a/AnkiDroid/src/main/res/values-sc/03-dialogs.xml b/AnkiDroid/src/main/res/values-sc/03-dialogs.xml index 36962b87940a..c0c6451dff42 100644 --- a/AnkiDroid/src/main/res/values-sc/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sc/03-dialogs.xml @@ -124,7 +124,7 @@ Base de datos blocada - Sa base de datos de AnkiDroid l\'est giai impreende un\'àtera aplicatzione. Pro praghere serra s\'àtera aplicatzione e a pustis torra a abèrrere AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versione de sa base de datos non cumpatìbile Sa base de datos est una versione prus avantzada de sas chi custa versione de AnkiDroid potzat gestire. Atualiza AnkiDroid o cunverte sa base de datos a una versione prus betza pro l\'aberrere\n \n diff --git a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml index 9d9dd2c2a1d5..a3640329c6e7 100644 --- a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml @@ -123,7 +123,7 @@ Databáza uzamknutá - Databázu AnkiDroid používa iná aplikácia. Zavrite, prosím, danú aplikáciu a potom znovu otvorte AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Nekompatibilná verzia databázy Databáza je pokročilejšej verzie ako vie AnkiDroid pracovať. Aktualizujte AnkiDroid alebo zmeňte verziu databázy na nižšiu verziu\n\nPodporovaná verzia: %1$d\nVerzia databázy: %2$d\n\nNasledovné možnosti obnovenia prepíšu vašu aktuálnu kolekciu, pravdepodobne kompatibilnou verziou databázy: diff --git a/AnkiDroid/src/main/res/values-sl/03-dialogs.xml b/AnkiDroid/src/main/res/values-sl/03-dialogs.xml index d39d9c0f91c0..9a6b14c936ad 100644 --- a/AnkiDroid/src/main/res/values-sl/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sl/03-dialogs.xml @@ -123,7 +123,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-sq/03-dialogs.xml b/AnkiDroid/src/main/res/values-sq/03-dialogs.xml index aea0f6d16223..f5e84f495bc5 100644 --- a/AnkiDroid/src/main/res/values-sq/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sq/03-dialogs.xml @@ -117,7 +117,7 @@ Baza e të dhënave është e kyçur - Baza e të dhënave AnkiDroid është në përdorim nga një aplikacion tjetër. Mbyllni aplikacionin tjetër dhe rihapni AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Versioni i papajtueshëm i bazës së të dhënave Baza e të dhënave është një version më i avancuar sesa mund të punojë ky version i AnkiDroid. Përmirësoni AnkiDroid ose ulni bazën e të dhënave për ta hapur atë\n\nVersioni i mbështetur: %1$d\nVersioni i bazës së të dhënave: %2$d\n\nOpsionet e mëposhtme të rivendosjes do të mbishkruajnë koleksionin tuaj aktual, ndoshta me një version të përputhshëm të bazës së të dhënave: diff --git a/AnkiDroid/src/main/res/values-sr/03-dialogs.xml b/AnkiDroid/src/main/res/values-sr/03-dialogs.xml index 75ad76c00ad7..dd24ffa59b73 100644 --- a/AnkiDroid/src/main/res/values-sr/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sr/03-dialogs.xml @@ -120,7 +120,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-sv/03-dialogs.xml b/AnkiDroid/src/main/res/values-sv/03-dialogs.xml index 43540b076141..8ef8ba9584ab 100644 --- a/AnkiDroid/src/main/res/values-sv/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sv/03-dialogs.xml @@ -117,7 +117,7 @@ Databas låst - AnkiDroid-databasen används av en annan applikation. Stäng den andra applikationen och öppna sedan AnkiDroid igen. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Inkompatibel databasversion Databasen är en mer avancerad version än denna version av AnkiDroid kan arbeta med. Uppgradera AnkiDroid eller nedgradera databasen för att öppna den\n\nVersion som stöds: %1$d\nDatabasversion: %2$d\n\nFöljande återställningsalternativ kommer att skriva över din nuvarande samling, eventuellt med en kompatibel databasversion: diff --git a/AnkiDroid/src/main/res/values-ta/03-dialogs.xml b/AnkiDroid/src/main/res/values-ta/03-dialogs.xml index 02e2f50297ea..e91946d76bb9 100644 --- a/AnkiDroid/src/main/res/values-ta/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ta/03-dialogs.xml @@ -117,7 +117,7 @@ தரவுத்தளம் பூட்டப்பட்டது - AnkiDroid தரவுத்தளமானது மற்றொரு பயன்பாட்டினால் பயன்பாட்டில் உள்ளது. தயவுசெய்து மற்ற பயன்பாட்டை மூடிவிட்டு AnkiDroid ஐ மீண்டும் திறக்கவும். + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. பொருந்தாத தரவுத்தள பதிப்பு AnkiDroid இன் இந்த பதிப்பு வேலை செய்யக்கூடியதை விட தரவுத்தளமானது மிகவும் மேம்பட்ட பதிப்பாகும். AnkiDroid ஐ மேம்படுத்தவும் அல்லது அதைத் திறக்க தரவுத்தளத்தை தரமிறக்கவும்\n\nஆதரிக்கப்படும் பதிப்பு: %1$d\nடேட்டாபேஸ் பதிப்பு: %2$d\n\nபின்வரும் மீட்டெடுப்பு விருப்பங்கள் உங்கள் தற்போதைய சேகரிப்பை மேலெழுதும், ஒருவேளை இணக்கமான தரவுத்தள பதிப்பில்: diff --git a/AnkiDroid/src/main/res/values-te/03-dialogs.xml b/AnkiDroid/src/main/res/values-te/03-dialogs.xml index 12d75bff7b95..cbbacd10e9b8 100644 --- a/AnkiDroid/src/main/res/values-te/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-te/03-dialogs.xml @@ -117,7 +117,7 @@ డేటాబేస్ లాక్ చేయబడింది - AnkiDroid డేటాబేస్ మరొక అప్లికేషన్ ద్వారా ఉపయోగంలో ఉంది. దయచేసి ఇతర అప్లికేషన్‌ను మూసివేసి, ఆపై AnkiDroidని మళ్లీ తెరవండి. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. అననుకూల డేటాబేస్ వెర్షన్ డేటాబేస్ అనేది AnkiDroid యొక్క ఈ సంస్కరణతో పని చేయగల దాని కంటే మరింత అధునాతన సంస్కరణ. AnkiDroidని అప్‌గ్రేడ్ చేయండి లేదా డేటాబేస్‌ని తెరవడానికి డౌన్‌గ్రేడ్ చేయండి\n\nమద్దతు ఉన్న సంస్కరణ: %1$d\nడేటాబేస్ వెర్షన్: %2$d\n\nక్రింది పునరుద్ధరణ ఎంపికలు మీ ప్రస్తుత సేకరణను భర్తీ చేస్తాయి, బహుశా అనుకూల డేటాబేస్ వెర్షన్‌తో: diff --git a/AnkiDroid/src/main/res/values-tgl/03-dialogs.xml b/AnkiDroid/src/main/res/values-tgl/03-dialogs.xml index ea1411450e68..ee258aaee847 100644 --- a/AnkiDroid/src/main/res/values-tgl/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-tgl/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-th/03-dialogs.xml b/AnkiDroid/src/main/res/values-th/03-dialogs.xml index 7a5ec3b6bd2a..df962e73bd88 100644 --- a/AnkiDroid/src/main/res/values-th/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-th/03-dialogs.xml @@ -114,7 +114,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ti/03-dialogs.xml b/AnkiDroid/src/main/res/values-ti/03-dialogs.xml index 09d2f5c37d8f..0ed592be4794 100644 --- a/AnkiDroid/src/main/res/values-ti/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ti/03-dialogs.xml @@ -117,7 +117,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-tr/01-core.xml b/AnkiDroid/src/main/res/values-tr/01-core.xml index c2bd9437ab8e..945ce6788249 100644 --- a/AnkiDroid/src/main/res/values-tr/01-core.xml +++ b/AnkiDroid/src/main/res/values-tr/01-core.xml @@ -128,7 +128,7 @@ varolan notlara, kartlara, not türlerine ve destelere erişmek, ve yenilerini oluşturmak Kart tarayıcısı - Anki card + Anki kartı Koleksiyonlar birleştirilemiyor.\nHangi koleksiyonu tutmak istersiniz? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-tr/02-strings.xml b/AnkiDroid/src/main/res/values-tr/02-strings.xml index 9b8ab5358ddf..8c0628ea2dae 100644 --- a/AnkiDroid/src/main/res/values-tr/02-strings.xml +++ b/AnkiDroid/src/main/res/values-tr/02-strings.xml @@ -236,7 +236,7 @@ Araç çubuğu öğesini düzenle Seçili yazıdan önce ve sonra eklenecek HTML girin\n\nBir araç çubuğu öğesini değiştirmek ya da kaldırmak için basılı tutun Araç çubuğu öğesi kaldırılsın mı? - Button text is required + Düğme metni gerekiyor En az bir HTML alanı gereklidir. Bu resim fazla büyük, lütfen resmi elle ekleyin Bu video dosyası fazla büyük, lütfen videoyu elle ekleyin @@ -275,8 +275,8 @@ %1$s/s -/s - - Speed - Time left + Hız + Kalan süre İndirme devam ederken diğer uygulamaları kullanabilirsiniz Lütfen ağ bağlantını kontrol et İndirme iptal edildi, harici hafıza erişilebilir değil. diff --git a/AnkiDroid/src/main/res/values-tr/03-dialogs.xml b/AnkiDroid/src/main/res/values-tr/03-dialogs.xml index bf9f336f5dd1..9d37c289520f 100644 --- a/AnkiDroid/src/main/res/values-tr/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-tr/03-dialogs.xml @@ -78,7 +78,7 @@ Üzerine Yaz Onar Değiştir - %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\nTarih ve zamanı düzeltin, veya otomatik tarih ve zamanı açın, sonra tekrar senkronize edin.\n\nCihazınızın güncel zamanı: %2$s Belirtilen konum geçerli bir dizin değildi. Verilen metin geçerli bir PEM-kodlanmış X509 sertifikasına çözümlenmiyor Sertifika çözümlemesinde hata @@ -117,7 +117,7 @@ Veritabanı kilitli - AnkiDroid veritabanı başka bir uygulama tarafından kullanılıyor. Lütfen diğer uygulamayı kapatın ve ardından AnkiDroid\'i yeniden açın. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Uyumsuz Veritabanı Sürümü Veritabanı, AnkiDroid\'in bu sürümünün çalışabileceğinden daha gelişmiş bir sürümdür. Açmak için AnkiDroid\'i yükseltin veya veritabanını düşürün\n\nDesteklenen sürüm: %1$d\nVeritabanı sürümü: %2$d\n\nAşağıdaki geri yükleme seçenekleri, muhtemelen uyumlu bir veritabanı sürümüyle mevcut koleksiyonunuzun üzerine yazacaktır: diff --git a/AnkiDroid/src/main/res/values-tr/10-preferences.xml b/AnkiDroid/src/main/res/values-tr/10-preferences.xml index 5c67c9168d35..043485bb4a9f 100644 --- a/AnkiDroid/src/main/res/values-tr/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-tr/10-preferences.xml @@ -222,75 +222,75 @@ ################################## Review reminders ################################### ####################################################################################### --> - Review reminders + Çalışma hatırlatıcıları Uygulama geneli - Deck-specific - An error occurred while loading your review reminders, corrupted reminders have been deleted. Details: + Desteye özel + Çalışma hatırlatıcılarınızı yüklerken bir hata oldu, bozuk hatırlatıcılar silindi. Detaylar: - Schedule reminders + Hatırlatıcı kur Hatırlatıcı ekle Çalışma hatırlatıcınız yok - Troubleshoot + Sorun gider Bildirim ayarları - List of scheduled review reminders + Kurulmuş çalışma hatırlatıcılarının listesi Deste bulunamadı - Reminders are unavailable - Fix - Successfully added new review reminder - Successfully deleted review reminder - Successfully edited review reminder + Hatırlatıcılar kullanılamıyor + Düzelt + Başarılı bir şekilde yeni çalışma hatırlatıcısı eklendi + Başarılı bir şekilde çalışma hatırlatıcısı silindi + Başarılı bir şekilde çalışma hatırlatıcısı düzenlendi Çalışma hatırlatıcısı ekle Çalışma hatırlatıcısını değiştir - Time: + Saat: Deste: Gelişmiş ayarlar - 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 + Kart eşiği: + Çalışılacak bundan az sayıda kart varsa bildirim gönderme + Sadece bugün hiç çalışmadıysam bildirim gönder + Lütfen tam bir kart sayısı girin + Eşik en az 0 olmalıdır Bu hatırlatıcı silinsin mi? Bu eylem geri alınamaz. - 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. + Hatırlatıcılar kullanılamıyor. + Hatırlatıcılar doğru çalışmayabilir. + Hatırlatıcılarınız beklendiği gibi çalışıyor olmalı. + Farklı cihazların farklı bildirim kısıtlama ayarları olur. Sistem ayarlarınızı kontrol edin veya sorunlar devam ederse internette araştırın. - Notification permission + Bildirim izni Bildirim kanalı - Do not disturb + Rahatsız etme Pil optimizasyonu Güç tasarruf modu - Alarms & reminders permission + Alarmlar ve hatırlatıcılar izni - Granted - Denied + Verilmiş + Reddedilmiş Açık Kapalı - Unrestricted - Optimized - Restricted + Kısıtsız + Optimize + Kısıtlı - 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 + Çalışma hatırlatıcısı bildirim kanalı açık olmalıdır + Rahatsız Etme hatırlatıcı bildirimlerini sessizleştirebilir + Pil optimizasyonu hatırlatıcıları geciktirebilir + Arka plan kullanımı kapalı. Hatırlatıcılar gönderilemeyebilir + Enerji tasarrufu modu hatırlatıcıların zamanında gelmesini önleyebilir + Kesin zamanlı hatırlatıcılar kurmak için gerekir - Grant permission - Enable notification channel - Disable battery optimization - Open battery settings + İzin ver + Bildirim kanalını aç + Pil optimizasyonunu kapat + Pil ayarlarını aç - It\'s time to study your cards - It\'s time to study \"%s\" - Snooze %s + Kartlarınızı çalışma zamanı + \"%s\" çalışma zamanı + %s ertele - %d card due - %d cards due + %d kartın sırası geldi + %d kartın sırası geldi Etiketleri Seçin @@ -310,7 +310,7 @@ Hakkında Katkıda Bulunanlar insanlar. Siz de AnkiDroid\'e programlayarak, çevirerek, bağış yaparak ve daha fazla yolla yardım edebilirsiniz!]]> - contributors. You can help AnkiDroid too by programming, translating and more!]]> + katkıda bulunan harika insanlar. Siz de AnkiDroid\'e programlama, çeviri ve daha fazlasını yaparak yardımcı olabilirsiniz!]]> Lisans GPL-3.0 ve AGPL-3.0 lisansları altında yayınlanmaktadır, kaynak kodu GitHub\'da mevcuttur]]> lisanslar altında üçüncü parti kütüphaneler ve simgeler de kullanır.]]> @@ -441,9 +441,9 @@ geri bildiriminizi veya bulduğunuz hataları bildirin.]]> Cevap geribildirimini göster Ekran - Also assign to ‘%1$s’? - Skip - Assign + \"%1$s\"e de atansın mı? + Geç + Ata Ayarları aç İlgili ayarlar diff --git a/AnkiDroid/src/main/res/values-tt/03-dialogs.xml b/AnkiDroid/src/main/res/values-tt/03-dialogs.xml index e6bcc66b3096..2ed6148b29b6 100644 --- a/AnkiDroid/src/main/res/values-tt/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-tt/03-dialogs.xml @@ -114,7 +114,7 @@ Мәгълүматлар базасы бикләнгән - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: diff --git a/AnkiDroid/src/main/res/values-ug/03-dialogs.xml b/AnkiDroid/src/main/res/values-ug/03-dialogs.xml index b9238fe480f8..5e595dfc021e 100644 --- a/AnkiDroid/src/main/res/values-ug/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ug/03-dialogs.xml @@ -117,7 +117,7 @@ ساندان قۇلۇپلانغان - AnkiDroid سانداننى باشقا بىر ئەپ ئىشلىتىۋاتىدۇ. باشقا ئەپنى تاقاپ ئاندىن AnkiDroid نى قايتا ئېچىڭ. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. ماسلاشمايدىغان ساندان نەشرى بۇ ساندان AnkiDroid تا ئىشلەيدىغان نەشرىدىن ئالىي نەشرى ئىكەن. ئۇنى ئېچىش ئۈچۈن AnkiDroid نى يۈكسەلتىڭ ياكى ساندان نەشرىنى تۆۋەنلىتىڭ\n\nقوللايدىغان نەشرى: %1$d\nساندان نەشرى: %2$d\n\nتۆۋەندىكى ئەسلىگە كەلتۈرۈش تاللانمىسى نۆۋەتتىكى توپلامنى قاپلىۋېتىدۇ، ماسلىشىشى مۇمكىن بولغان ساندان نەشرى: diff --git a/AnkiDroid/src/main/res/values-uk/03-dialogs.xml b/AnkiDroid/src/main/res/values-uk/03-dialogs.xml index 5b63e69f28bb..b6189617698a 100644 --- a/AnkiDroid/src/main/res/values-uk/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-uk/03-dialogs.xml @@ -123,7 +123,7 @@ База даних заблокована - База даних AnkiDroid використовується іншою програмою. Будь ласка, закрийте іншу програму, а потім повторно відкрийте AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Несумісна версія бази даних Версія бази даних занадто нова, і ця версія AnkiDroid несумісна з нею. Оновіть AnkiDroid або скористайтеся більш старою версією бази даних, щоб відкрити її\n\nПідтримувана версія: %1$d\nВерсія бази даних: %2$d\n\nНаступні варіанти відновлення перезапишуть поточну колекцію,, можливо, з сумісною версією бази даних: diff --git a/AnkiDroid/src/main/res/values-ur/03-dialogs.xml b/AnkiDroid/src/main/res/values-ur/03-dialogs.xml index 161fa7a60e06..682dff5f7fd4 100644 --- a/AnkiDroid/src/main/res/values-ur/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ur/03-dialogs.xml @@ -117,7 +117,7 @@ ڈیٹا بیس مقفل - AnkiDroid ڈیٹا بیس ایک اور ایپلیکیشن کے زیر استعمال ہے۔ براہ کرم دوسری ایپلیکیشن بند کریں پھر AnkiDroid کو دوبارہ کھولیں۔ + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. غیر مطابقت پذیر ڈیٹا بیس ورژن ڈیٹا بیس AnkiDroid کے اس ورژن سے زیادہ جدید ورژن ہے جس کے ساتھ کام کیا جا سکتا ہے۔ AnkiDroid کو اپ گریڈ کریں یا اسے کھولنے کے لیے ڈیٹا بیس کو ڈاؤن گریڈ کریں\n\nتعاون یافتہ ورژن: %1$d\nڈیٹا بیس ورژن: %2$d\n\n درج ذیل بحالی کے اختیارات آپ کے موجودہ مجموعہ کو اوور رائٹ کر دیں گے، ممکنہ طور پر ایک مطابقت پذیر ڈیٹا بیس ورژن کے ساتھ: diff --git a/AnkiDroid/src/main/res/values-uz/03-dialogs.xml b/AnkiDroid/src/main/res/values-uz/03-dialogs.xml index f882da29787d..72194905cf5e 100644 --- a/AnkiDroid/src/main/res/values-uz/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-uz/03-dialogs.xml @@ -117,7 +117,7 @@ Maʼlumotlar bazasi qulflandi - AnkiDroid maʼlumotlar bazasi boshqa dastur tomonidan foydalanilmoqda. Iltimos, oʻsha ilovani yoping va AnkiDroidʼni boshqatdan oching. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Mos kelmaydigan maʼlumotlar bazasi versiyasi Maʼlumotlar bazasi versiyasi AnkiDroid ishlay oladigan versiyadan ancha yuqori. Ochish uchun AnkiDroidʼni yangilang yoki maʼlumotlar baza versiyasini tushiring\n\nQoʻllab-quvvatlanadigan versiya: %1$d\nMaʼlumotlar bazasi versiyasi: %2$d\n\nQuyidagi tiklash variantlar mavjud kolleksiyangizni mos keladigan maʼlumotlar bazasi versiyasi bilan qayta yozishi mumkin: diff --git a/AnkiDroid/src/main/res/values-vi/03-dialogs.xml b/AnkiDroid/src/main/res/values-vi/03-dialogs.xml index f9a5fdbc93f6..197fb2123acb 100644 --- a/AnkiDroid/src/main/res/values-vi/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-vi/03-dialogs.xml @@ -114,7 +114,7 @@ Cơ sở dữ liệu đã khóa - Cơ sở dữ liệu AnkiDroid đang được một ứng dụng khác sử dụng. Vui lòng đóng ứng dụng khác sau đó mở lại AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Phiên bản Database không tương thích Cơ sở dữ liệu là phiên bản nâng cao hơn phiên bản AnkiDroid này có thể hoạt động. Nâng cấp AnkiDroid hoặc hạ cấp cơ sở dữ liệu để mở nó\n\nPhiên bản được hỗ trợ:%1$d%1$d\nPhiên bản cơ sở dữ liệu:%2$d%2$d\n\nCác tùy chọn khôi phục sau sẽ ghi đè bộ sưu tập hiện tại của bạn, có thể với phiên bản cơ sở dữ liệu tương thích: diff --git a/AnkiDroid/src/main/res/values-zh-rCN/03-dialogs.xml b/AnkiDroid/src/main/res/values-zh-rCN/03-dialogs.xml index b6af88b8a48b..e419114eeede 100644 --- a/AnkiDroid/src/main/res/values-zh-rCN/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-zh-rCN/03-dialogs.xml @@ -114,7 +114,7 @@ 数据库已锁定 - AnkiDroid 数据库正在被另一个应用程序使用。请关闭另一个应用程序然后重新打开 AnkiDroid。 + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. 不兼容的数据库版本 数据库的版本比当前 AnkiDroid 兼容的版本更高。升级 AnkiDroid 或降级数据库以打开它\n\n兼容的版本:%1$d\n数据库版本:%2$d\n\n以下恢复选项将覆盖您当前的集合,可能有兼容的数据库版本: diff --git a/AnkiDroid/src/main/res/values-zh-rTW/03-dialogs.xml b/AnkiDroid/src/main/res/values-zh-rTW/03-dialogs.xml index 45cc2408ed22..baff2000ef00 100644 --- a/AnkiDroid/src/main/res/values-zh-rTW/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-zh-rTW/03-dialogs.xml @@ -114,7 +114,7 @@ 資料庫已鎖定 - AnkiDroid 的資料庫正在被其他應用程式使用中。請關閉其他程式再重新開啟 AnkiDroid。 + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. 不相容的資料庫版本 該資料庫比此版本的 AnkiDroid 可以使用的更高。升級 AnkiDroid 或降級資料庫以開啟它\n\n支援的版本:%1$d\n資料庫版本:%2$d\n\n以下還原選項將覆蓋您當前的集合,通過使用可能相容的資料庫版本: diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index d028635b97ae..a7b1a4124c4f 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -119,7 +119,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version diff --git a/AnkiDroid/src/main/res/values/analytic_constants.xml b/AnkiDroid/src/main/res/values/analytic_constants.xml index 5b777324e5ab..c3dd464ce67d 100644 --- a/AnkiDroid/src/main/res/values/analytic_constants.xml +++ b/AnkiDroid/src/main/res/values/analytic_constants.xml @@ -11,7 +11,7 @@ 4. Copy the Measurement ID shown at the top of the stream details The matching Measurement Protocol api_secret is read at build time as - BuildConfig.ANALYTICS_API_KEY (see AnkiDroid/build.gradle). Create it + BuildConfig.ANALYTICS_API_KEY (see AnkiDroid/build.gradle.kts). Create it under the same Data Stream -> Measurement Protocol API secrets. --> G-XY625S5VVE diff --git a/AnkiDroid/src/main/res/values/integers.xml b/AnkiDroid/src/main/res/values/integers.xml new file mode 100644 index 000000000000..ba990e5b9f22 --- /dev/null +++ b/AnkiDroid/src/main/res/values/integers.xml @@ -0,0 +1,5 @@ + + + + 2 + diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt index 25362955c266..bfa3565d3349 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt @@ -38,6 +38,7 @@ class ActivityStartupMetaTest : RobolectricTest() { .filter { !it.startsWith("org.acra") } .filter { !it.startsWith("leakcanary.internal") } .filter { it != "com.canhub.cropper.CropImageActivity" } + .filter { it != "com.jakewharton.processphoenix.PhoenixActivity" } .toTypedArray() MatcherAssert.assertThat(testedActivityClassNames, Matchers.containsInAnyOrder(*manifestActivityNames)) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUnderBackupTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUnderBackupTest.kt index ce66c018cdda..ae7598d5bc3f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUnderBackupTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUnderBackupTest.kt @@ -3,6 +3,7 @@ package com.ichi2.anki import android.app.Activity +import android.content.Intent import android.os.Looper.getMainLooper import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity import com.ichi2.anki.preferences.PreferencesActivity @@ -19,7 +20,9 @@ import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ActivityController +import org.robolectric.shadows.ShadowToast import java.util.stream.Collectors +import kotlin.test.assertNull @RunWith(ParameterizedRobolectricTestRunner::class) class ActivityStartupUnderBackupTest : RobolectricTest() { @@ -35,8 +38,6 @@ class ActivityStartupUnderBackupTest : RobolectricTest() { @Before fun before() { - notYetHandled(IntentHandler::class.java.simpleName, "Not working (or implemented) - inherits from Activity") - notYetHandled(IntentHandler2::class.java.simpleName, "Not working (or implemented) - inherits from Activity") notYetHandled( PreferencesActivity::class.java.simpleName, "Not working (or implemented) - inherits from AppCompatPreferenceActivity", @@ -84,6 +85,13 @@ $stackTrace""", // But we get the main idea: onCreate() doesn't throw an exception and is handled. // and onDestroy() is also called in the real implementation on my phone. assertThat("If a backup was taking place, the activity should be finishing", controller.get()!!.isFinishing, equalTo(true)) + if (controller.get() is AbstractIntentHandler) { + assertNull(shadowOf(controller.get()).nextStartedActivity, "The intent must not be dispatched during backup recovery") + assertThat( + ShadowToast.getTextOfLatestToast(), + equalTo(targetContext.getString(CommonString.ankidroid_cannot_open_after_backup_try_again)), + ) + } controller.destroy() assertThat( "If a backup was taking place, the activity should be destroyed successfully", @@ -110,6 +118,12 @@ $stackTrace""", .stream() .map { x: ActivityLaunchParam -> arrayOf(x, x.simpleName) - }.collect(Collectors.toList()) + }.collect(Collectors.toList()) + + listOf(Intent.ACTION_MAIN, "com.ichi2.anki.DO_SYNC").map { action -> + arrayOf( + ActivityLaunchParam.get(IntentHandler::class.java) { Intent(action) }, + "IntentHandler $action", + ) + } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index 5c2fb4767bd6..858506cc917c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -43,6 +43,7 @@ import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper +import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooperUntil import com.ichi2.anki.browser.BrowserColumnKey import com.ichi2.anki.browser.BrowserMultiColumnAdapter import com.ichi2.anki.browser.BrowserMultiColumnAdapter.Companion.LINES_VISIBLE_WHEN_COLLAPSED @@ -501,8 +502,8 @@ class CardBrowserTest : RobolectricTest() { } ActivityScenario.launch(deepLink).use { scenario -> - advanceRobolectricLooper() scenario.onActivity { browser -> + browser.waitForSearchResults() assertThat("the deep link's search is applied", browser.viewModel.searchTerms, equalTo("dog")) assertThat("only the matching note is shown", browser.viewModel.rowCount, equalTo(1)) } @@ -518,8 +519,8 @@ class CardBrowserTest : RobolectricTest() { fun CardBrowser.subtitle() = findViewById(R.id.subtitle).text.toString() ActivityScenario.launch(CardBrowser::class.java).use { scenario -> - advanceRobolectricLooper() scenario.onActivity { browser -> + browser.waitForSearchResults() assertThat("card count before recreation", browser.subtitle(), equalTo("2 cards shown")) } @@ -2271,11 +2272,11 @@ fun getBrowserWithNotes( test.addBasicNote(i.toString(), "back") } } - return test.startRegularActivity(Intent()).also { - advanceRobolectricLooper() // may be a fix for flaky tests - } + return test.startRegularActivity(Intent()).also { it.waitForSearchResults() } } +fun CardBrowser.waitForSearchResults() = advanceRobolectricLooperUntil { viewModel.searchJob?.isCompleted == true } + context(test: RobolectricTest) fun withCardBrowser( noteCount: Int, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt index 8282cf475a3c..8c56fa4b4368 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt @@ -8,6 +8,7 @@ import com.ichi2.anki.CollectionManager.tryWithCol import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CollectionManager.withColExclusive import com.ichi2.anki.CollectionManager.withLeaseForTest +import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -19,7 +20,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import net.ankiweb.rsdroid.Backend -import net.ankiweb.rsdroid.BackendException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.notNullValue @@ -395,7 +395,7 @@ class CollectionLeaseTest : RobolectricTest() { runTest { CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED try { - assertFailsWith { + assertFailsWith { withColExclusive(CollectionOperation.SYNC) { error("collection could not open") } } assertThat(collectionLease, nullValue()) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt index 16a6eef4aea0..a37280942234 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt @@ -5,8 +5,8 @@ package com.ichi2.anki import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.exception.CollectionLockedException import net.ankiweb.rsdroid.BackendException -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import net.ankiweb.rsdroid.BackendException.BackendImportException import net.ankiweb.rsdroid.exceptions.BackendIoException import net.ankiweb.rsdroid.exceptions.BackendSyncException @@ -66,7 +66,7 @@ class CollectionManagerImportTest : RobolectricTest() { val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) } assertEquals(expectedMessage, failure.localizedMessage) - assertIs(failure.suppressed.single()) + assertIs(failure.suppressed.single()) } finally { CollectionManager.emulatedOpenFailure = null } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt index 1cf6d04952cd..7c3c407fa678 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt @@ -3,12 +3,14 @@ package com.ichi2.anki import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.test.StandardTestDispatcher import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -22,8 +24,9 @@ class CollectionManagerStackTraceTest : RobolectricTest() { try { CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED - val failure = assertFailsWith { requestCollection() } + val failure = assertFailsWith { requestCollection() } + assertIs(failure.cause) assertFalse(failure.stackTrace.any { it.methodName == "requestCollection" }) val caller = failure.suppressed.single() assertTrue(caller.stackTrace.any { it.methodName == "requestCollection" }) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt index 85e4729ed17d..83f638bfed51 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt @@ -3,9 +3,9 @@ package com.ichi2.anki import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.CollectionFiles -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.instanceOf @@ -51,8 +51,8 @@ class CollectionStorageModeTest( fun `test access respects collection opening failures`() = runTest { withNullCollection { - assertThat(runCatching { col }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java)) - assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java)) + assertThat(runCatching { col }.exceptionOrNull(), instanceOf(CollectionLockedException::class.java)) + assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(CollectionLockedException::class.java)) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt index 49c7d798aacd..156e95ad488f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt @@ -2,13 +2,23 @@ package com.ichi2.anki +import android.content.DialogInterface +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentActivity import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageNotConfiguredException +import com.ichi2.anki.preferences.PreferencesActivity +import com.ichi2.testutils.BackendEmulatingOpenConflict +import kotlinx.coroutines.test.runTest +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDialog import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -34,4 +44,73 @@ class CoroutineHelpersRobolectricTest : RobolectricTest() { assertEquals(IntentHandler::class.qualifiedName, redirect.component?.className) } } + + /** + * #21051: the collection lock is normally held by a second AnkiDroid install sharing the + * AnkiDroid folder. The backend's 'Anki already open' text doesn't explain this on Android, + * where the other app is invisible: [CollectionLockedException] carries guidance naming the + * likely cause instead. + */ + @Test + fun `launchCatchingTask explains a locked collection`() = + withLockedCollection { + throwOnShowError = false + val controller = Robolectric.buildActivity(FragmentActivity::class.java).also(::saveControllerForCleanup) + val activity = controller.create().get() + activity.setTheme(R.style.Theme_Light) + + activity.launchCatchingTask { withCol { } } + advanceRobolectricLooper() + + assertThat(getAlertDialogText(true), containsString("Advanced settings")) + } + + /** See `launchCatchingTask explains a locked collection`: the ViewModel error funnel */ + @Test + fun `launchCatching explains a locked collection`() = + withLockedCollection { + runTest { + var message: String? = null + + launchCatching(errorMessageHandler = { message = it }) { withCol { } }.join() + + assertThat(message, containsString("Advanced settings")) + } + } + + /** + * The locked-collection dialog offers Settings, opened at the Advanced screen, where + * changing the 'AnkiDroid directory' resolves the conflict + */ + @Test + fun `a locked collection error links to Advanced settings`() = + withLockedCollection { + throwOnShowError = false + val controller = Robolectric.buildActivity(FragmentActivity::class.java).also(::saveControllerForCleanup) + val activity = controller.create().get() + activity.setTheme(R.style.Theme_Light) + + activity.launchCatchingTask { withCol { } } + advanceRobolectricLooper() + + val helpButton = (ShadowDialog.getLatestDialog() as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL) + assertEquals("Settings", helpButton.text.toString()) + + helpButton.performClick() + advanceRobolectricLooper() + + val settings = shadowOf(activity).nextStartedActivity + assertNotNull(settings, "Advanced settings should be opened") + assertEquals(PreferencesActivity::class.qualifiedName, settings.component?.className) + } + + /** Emulates #21051: another AnkiDroid install holds the collection lock */ + private fun withLockedCollection(block: () -> Unit) { + BackendEmulatingOpenConflict.enable() + try { + block() + } finally { + BackendEmulatingOpenConflict.disable() + } + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 0bc617e090ba..3b0a2ea04790 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -4,6 +4,7 @@ package com.ichi2.anki import android.Manifest.permission.INTERNET import android.annotation.SuppressLint +import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager @@ -58,6 +59,7 @@ import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.model.SelectableDeck import com.ichi2.anki.navigation.AnkiDroidNavigator import com.ichi2.anki.observability.ChangeManager +import com.ichi2.anki.preferences.PreferencesActivity import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.RecyclerFastScroller @@ -375,6 +377,30 @@ class DeckPickerTest : RobolectricTest() { ) } + /** + * The 'Database Locked' dialog offers Settings, opened at the Advanced screen, where + * changing the 'AnkiDroid directory' resolves the conflict (#21051). Without it, the + * dialog's only button quits the app, contradicting the guidance in its own message. + */ + @Test + fun `database locked dialog links to Advanced settings`() { + val deckPicker = startRegularActivity() + deckPicker.showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_DB_LOCKED) + deckPicker.supportFragmentManager.executePendingTransactions() + advanceRobolectricLooper() + + val settingsButton = (ShadowDialog.getLatestDialog() as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL) + assertEquals("Settings", settingsButton.text.toString()) + + shadowOf(deckPicker).clearNextStartedActivities() + settingsButton.performClick() + advanceRobolectricLooper() + + val settings = shadowOf(deckPicker).nextStartedActivity + assertNotNull(settings, "Advanced settings should be opened") + assertEquals(PreferencesActivity::class.qualifiedName, settings.component?.className) + } + /** Until the storage setup flow exists (#19552), the user gets recovery options, not a crash */ @Test fun `storage undecided shows load-failure options rather than crashing`() { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DialogScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DialogScreenshotTest.kt new file mode 100644 index 000000000000..7c70f86a472d --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DialogScreenshotTest.kt @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.RecyclerView +import org.robolectric.Robolectric.buildActivity + +/** Screenshot fixtures for dialogs that can be hosted in an otherwise empty activity. */ +abstract class DialogScreenshotTest : ScreenshotTest() { + protected inline fun withDialog( + fragment: T, + block: (T) -> Unit, + ) { + buildActivity(AnkiActivity::class.java).use { controller -> + val activity = controller.setup().get() + fragment.showNow(activity.supportFragmentManager, "screenshot_dialog") + advanceRobolectricLooper() + block(fragment) + } + } + + /** Expands search, waits for a query which narrows the list, and captures the results. */ + protected fun captureSearchResults( + name: String, + list: RecyclerView, + searchItem: MenuItem, + query: String, + ) { + val initialCount = list.adapter!!.itemCount + searchItem.expandActionView() + (searchItem.actionView as SearchView).setQuery(query, false) + advanceRobolectricLooperUntil { list.adapter!!.itemCount in 1 until initialCount } + captureScreen("${name}_search") + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index ff9bf5eed72f..9029e33af1de 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -229,6 +229,7 @@ open class RobolectricTest : } WorkManagerTestInitHelper.closeWorkDatabase() Dispatchers.resetMain() + ioDispatcher = Dispatchers.IO runBlocking { CollectionManager.discardBackend() } val pendingMethods = OnlyOnce.pendingMethods Assert.assertTrue( @@ -385,7 +386,7 @@ open class RobolectricTest : } /** - * Emulates a null collection and a `BackendDbLockedException` while [block] runs, + * Emulates a null collection and a `CollectionLockedException` while [block] runs, * restoring normal collection behavior afterwards. * * @see enableNullCollection diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt index 4bf33d50b7db..f0dd6c3072fb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt @@ -128,6 +128,12 @@ abstract class ScreenshotTest : RobolectricTest() { } } + /** Applies the orientation and returns its component of the screenshot filename. */ + protected fun setOrientation(landscape: Boolean): String { + if (landscape) RuntimeEnvironment.setQualifiers("+land") + return if (landscape) "landscape" else "portrait" + } + /** Pixel-class phone in portrait */ protected fun setPhoneQualifiers() = RuntimeEnvironment.setQualifiers(RobolectricDeviceQualifiers.MediumPhone) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/SingleFragmentActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/SingleFragmentActivityTest.kt new file mode 100644 index 000000000000..d1f3664fe403 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/SingleFragmentActivityTest.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2026 Ashish Yadav + +package com.ichi2.anki + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.account.AccountActivity +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.shadows.ShadowToast +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class SingleFragmentActivityTest : RobolectricTest() { + @Test + fun `launch without a fragment name shows an error and finishes`() { + val intent = Intent(targetContext, AccountActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + val activity = startRegularActivity(intent) + + assertTrue(activity.isFinishing) + assertEquals(targetContext.getString(R.string.something_wrong), ShadowToast.getTextOfLatestToast()) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt new file mode 100644 index 000000000000..c527d3310765 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.account + +import android.widget.Button +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.textfield.TextInputEditText +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.throwOnShowError +import com.ichi2.testutils.BackendEmulatingOpenConflict +import com.ichi2.ui.TextInputEditField +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginFragmentTest : RobolectricTest() { + @Before + override fun setUp() { + super.setUp() + BackendEmulatingOpenConflict.enable() + } + + @After + override fun tearDown() { + super.tearDown() + BackendEmulatingOpenConflict.disable() + } + + /** + * #21051: the locked-collection guidance is multi-sentence, so it is shown in a dialog + * rather than truncated in the login screen's snackbar + */ + @Test + fun `login with a locked collection shows guidance`() { + throwOnShowError = false + val activity = startRegularActivity(AccountActivity.getIntent(targetContext)) + activity.findViewById(R.id.username).setText("user@example.com") + activity.findViewById(R.id.password).setText("hunter2") + + activity.findViewById