diff --git a/.github/report_emulator_test_failures.py b/.github/report_test_failures.py
similarity index 78%
rename from .github/report_emulator_test_failures.py
rename to .github/report_test_failures.py
index c686509c268a..718bcdbbfe3a 100644
--- a/.github/report_emulator_test_failures.py
+++ b/.github/report_test_failures.py
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
-"""Report failures and skipped tests from Android's JUnit XML using the standard library."""
+"""Report failures and skipped tests from JUnit XML using the standard library."""
+import argparse
import html
import os
from pathlib import Path
@@ -25,11 +26,11 @@ def read_ignored_tests(log_file):
return ignored - failed
-def main(results_dir, log_file=None):
+def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"):
failures = []
skipped = []
ignored = read_ignored_tests(log_file)
- for report in sorted(results_dir.rglob("TEST-*.xml")):
+ for report in sorted(results_dir.rglob(pattern)):
try:
root = ET.parse(report).getroot()
except (ET.ParseError, OSError) as error:
@@ -68,11 +69,11 @@ def main(results_dir, log_file=None):
print(failure, end="\n\n")
if skipped:
- title = f"Skipped tests ({len(skipped)})"
+ skipped_title = f"Skipped tests ({len(skipped)})"
if os.environ.get("GITHUB_ACTIONS") == "true":
- print(f"::group::{title}")
+ print(f"::group::{skipped_title}")
else:
- print(title, end="\n\n")
+ print(skipped_title, end="\n\n")
for skip in skipped:
print(skip, end="\n\n")
if os.environ.get("GITHUB_ACTIONS") == "true":
@@ -81,11 +82,11 @@ def main(results_dir, log_file=None):
if (failures or skipped) and os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
if failures:
- summary.write("### Emulator test failures\n\n")
+ summary.write(f"### {title}\n\n")
for failure in failures:
summary.write(f"
{html.escape(failure)}
\n\n")
if skipped:
- summary.write(f"{title}\n\n")
+ summary.write(f"{skipped_title}\n\n")
for skip in skipped:
summary.write(f"
{html.escape(skip)}
\n\n")
summary.write("\n\n")
@@ -94,4 +95,10 @@ def main(results_dir, log_file=None):
if __name__ == "__main__":
- sys.exit(main(Path(sys.argv[1]), Path(sys.argv[2]) if len(sys.argv) > 2 else None))
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("results_dir", type=Path)
+ parser.add_argument("log_file", type=Path, nargs="?")
+ parser.add_argument("--pattern", default="TEST-*.xml", help="Recursive report glob relative to results_dir")
+ parser.add_argument("--title", default="Test failures", help="Failure heading in the job summary")
+ args = parser.parse_args()
+ sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title))
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 3b016a6088a4..ebfb629b5715 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -17,7 +17,7 @@ Alternately, you may run the actions on your fork of `Anki-Android`.
| [Lint (JavaScript)](https://github.com/ankidroid/Anki-Android/blob/main/.github/workflows/lint.yml) | See script | Prettier, lint & code formatting |
| [Build Logic](https://github.com/ankidroid/Anki-Android/blob/main/.github/workflows/lint.yml) | `./gradlew -p buildSrc test` and `tools/validate-version-code.sh` | Version-code bump checks |
| [Unit Tests](https://github.com/ankidroid/Anki-Android/blob/main/.github/workflows/tests_unit.yml) | `./gradlew jacocoUnitTestReport --daemon` | Unit tests for the Android Project |
-| [Emulator Tests](https://github.com/ankidroid/Anki-Android/blob/main/.github/workflows/tests_emulator.yml) | `TEST_RELEASE_BUILD=true ./gradlew jacocoAndroidTestReport --daemon` | Emulator tests for the Android Project. CI runs `bash .github/disable_art_metrics.sh` and `adb shell settings put global hide_error_dialogs 1` first |
+| [Emulator Tests](https://github.com/ankidroid/Anki-Android/blob/main/.github/workflows/tests_emulator.yml) | `TEST_RELEASE_BUILD=true ./gradlew jacocoAndroidTestReport --daemon` | Emulator tests for the Android Project. CI runs `bash .github/disable_art_metrics.sh` and `adb shell settings put global hide_error_dialogs 1` first. Then runs `ReleaseSmokeTest` from `:baselineprofile` with `TEST_RELEASE_BUILD=false` |
| [CodeQL](https://github.com/ankidroid/Anki-Android/blob/main/.github/workflows/codeql.yml) | N/A | GitHub-only check. [Docs](https://codeql.github.com/) |
## Other Workflows
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 3a1a2cd53e7d..d5596bdd4277 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -9,6 +9,8 @@ on:
- 'gh-readonly-queue/**'
# Dependabot branches are already checked by the pull_request trigger.
- 'dependabot/**'
+ # Crowdin translations are already checked by the pull_request trigger.
+ - 'i18n_sync'
merge_group:
permissions:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index c81eed38e5e8..7080bae85db7 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -123,13 +123,13 @@ jobs:
gunzip *.gz
shell: bash
+ - name: Clone ankidroiddocs for the changelog
+ run: git clone git@github.com:ankidroid/ankidroiddocs ../ankidroiddocs
+ shell: bash
+
- name: Build and Release public release
if: "${{ github.event.inputs.public != '' }}"
- run: |
- pushd ../
- git clone git@github.com:ankidroid/ankidroiddocs
- popd
- ./tools/release.sh public
+ run: ./tools/release.sh public
shell: bash
- name: Build and Release test release
diff --git a/.github/workflows/screenshot_compare.yml b/.github/workflows/screenshot_compare.yml
index 3fe003a8fb0c..0d9599f012d4 100644
--- a/.github/workflows/screenshot_compare.yml
+++ b/.github/workflows/screenshot_compare.yml
@@ -85,12 +85,16 @@ jobs:
SUBDIR: ${{ github.event_name == 'pull_request' && format('screenshot-diff-pr-{0}', github.event.number) || format('screenshot-diff-{0}', github.ref_name) }}
run: |
# Trim 'AnkiDroid/build/outputs/roborazzi' from the artifact
- # Only keep diagnostic content for failing cases: each /diffs/ becomes /
+ # Only keep the _compare images of failing cases: /diffs/*_compare.png moves to /
mkdir -p "screenshot-diff/$SUBDIR"
shopt -s nullglob
for diffs_dir in AnkiDroid/build/outputs/roborazzi/*/diffs; do
class_name="$(basename "$(dirname "$diffs_dir")")"
- mv "$diffs_dir" "screenshot-diff/$SUBDIR/$class_name"
+ compares=("$diffs_dir"/*_compare.png)
+ if (( ${#compares[@]} )); then
+ mkdir -p "screenshot-diff/$SUBDIR/$class_name"
+ mv "${compares[@]}" "screenshot-diff/$SUBDIR/$class_name/"
+ fi
done
- name: Upload screenshot diffs
diff --git a/.github/workflows/sync_translations.yml b/.github/workflows/sync_translations.yml
index aa802d1d9489..8dda8545ebbc 100644
--- a/.github/workflows/sync_translations.yml
+++ b/.github/workflows/sync_translations.yml
@@ -83,7 +83,7 @@ jobs:
cd ./tools/localization
yarn start extract
- - name: Update translation to AnkiDroid res
+ - name: Update translations in common resources
run: |
cd ./tools/localization
yarn start update
@@ -91,7 +91,7 @@ jobs:
- name: Check for changes and handle results
id: tr_check
run: |
- git add docs/marketing/localized_description AnkiDroid/src/main/res
+ git add docs/marketing/localized_description common/android/src/main/res
if git diff --cached --quiet; then
echo "HAS_CHANGES=false" >> $GITHUB_OUTPUT
echo "Translation Sync Result: No new translation found on Crowdin. No Pull Request necessary" >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml
index b6da152e6d51..a8e5dd5abd77 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:
@@ -15,13 +18,15 @@ on:
- 'gh-readonly-queue/**'
# Dependabot branches are already checked by the pull_request trigger.
- 'dependabot/**'
+ # Crowdin translations are already checked by the pull_request trigger.
+ - 'i18n_sync'
merge_group:
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 +61,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 +68,14 @@ jobs:
// try different architectures, targets, delays etc
let arch = ['x86_64'];
let target = ['google_apis', 'google_apis_ps16k'];
+ // Keep API 37 and the 16 KB emulator off the per-change CI path.
+ // Scheduled and manual runs exercise both API levels and targets.
+ if (context.eventName !== 'schedule' && context.eventName !== 'workflow_dispatch') {
+ target = ['google_apis'];
+ } else {
+ // Keep the minor version: SDK 37 packages use android-37.0.
+ apiLevel.push('37.0');
+ }
let firstBootDelay = [600];
return {
@@ -94,6 +98,14 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
TEST_RELEASE_BUILD: true
+ # Capture startup and kernel logs before ADB is ready; -logcat conflicts with -show-kernel.
+ EMULATOR_OPTIONS: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -verbose -show-kernel
+ # API 37 otherwise defaults to 96 MiB and panics during init with an OOM.
+ EMULATOR_RAM_SIZE: ${{ matrix.api-level == '37.0' && '2560M' || '' }}
+ # Keep the SDK package at 37.0, but ensure the AVD is recognized as API 37.
+ # API 3 fallback disables the DMA readback required by API 37's graphics mapper.
+ EMULATOR_PRE_LAUNCH_SCRIPT: >-
+ if [ '${{ matrix.api-level }}' = '37.0' ]; then sed -i 's/^target=.*/target=android-37/' "$ANDROID_AVD_HOME/test.ini" "$ANDROID_AVD_HOME/test.avd/config.ini"; fi
strategy:
fail-fast: false
matrix: ${{fromJson(needs.matrix_prep.outputs.matrix)}}
@@ -138,17 +150,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
@@ -159,7 +170,7 @@ jobs:
timeout_minutes: 15
retry_wait_seconds: 60
max_attempts: 3
- command: ./gradlew packagePlayRelease packagePlayReleaseAndroidTest --daemon
+ command: ./gradlew packagePlayRelease packagePlayReleaseAndroidTest :baselineprofile:packageBenchmarkBenchmark --daemon
- name: Install Android Emulator
# Retry to work around https://github.com/ReactiveCircus/android-emulator-runner/issues/496
@@ -191,16 +202,18 @@ 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 }}
force-avd-creation: false
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
- emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ emulator-options: ${{ env.EMULATOR_OPTIONS }}
+ ram-size: ${{ env.EMULATOR_RAM_SIZE }}
+ pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }}
sdcard-path-or-size: 100M
disable-animations: true
# Give the emulator a little time to run and do first boot stuff before taking snapshot
@@ -212,10 +225,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
@@ -224,7 +237,9 @@ jobs:
force-avd-creation: false
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
- emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ emulator-options: ${{ env.EMULATOR_OPTIONS }}
+ ram-size: ${{ env.EMULATOR_RAM_SIZE }}
+ pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }}
sdcard-path-or-size: 100M
disable-animations: true
# Restart zygote to win a config race / Give emulator time to run and do first boot stuff before taking snapshot
@@ -245,7 +260,9 @@ jobs:
force-avd-creation: false
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }}
+ ram-size: ${{ env.EMULATOR_RAM_SIZE }}
+ pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }}
sdcard-path-or-size: 100M
disable-animations: true
# update workflows/README.md when modifying this script
@@ -257,10 +274,11 @@ jobs:
bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm
sleep 5
bash .github/with_emulator_log.sh adb-log.txt python3 .github/run_emulator_tests.py
+ TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon
- name: Report Emulator Test Results
if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != ''
- run: python3 .github/report_emulator_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt
+ run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures"
- name: Configure NDK tools PATH for alignment checking
run: |
diff --git a/.github/workflows/tests_unit.yml b/.github/workflows/tests_unit.yml
index 1066288b4ccf..13b2b4071a82 100644
--- a/.github/workflows/tests_unit.yml
+++ b/.github/workflows/tests_unit.yml
@@ -28,6 +28,8 @@ on:
- 'gh-readonly-queue/**'
# Dependabot branches are already checked by the pull_request trigger.
- 'dependabot/**'
+ # Crowdin translations are already checked by the pull_request trigger.
+ - 'i18n_sync'
merge_group:
permissions:
@@ -177,6 +179,10 @@ jobs:
if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && matrix.iteration == 1 && matrix.os == 'ubuntu-latest'
run: ./gradlew testFullDebugUnitTest -PemptyApplication --daemon
+ - name: Report Unit Test Results
+ if: always() && hashFiles('**/build/test-results/**/TEST-*.xml') != ''
+ run: python3 .github/report_test_failures.py . --pattern "build/test-results/**/TEST-*.xml" --title "Unit test failures"
+
- name: Store Logcat as Artifact
# cancelled() handles test timeouts
# remove when test timeouts cause a failure()
diff --git a/.idea/dictionaries/android.xml b/.idea/dictionaries/android.xml
index 37e75752e3b6..040a008bba24 100644
--- a/.idea/dictionaries/android.xml
+++ b/.idea/dictionaries/android.xml
@@ -10,6 +10,7 @@
FILESIZEONEPLUSRoborazzi
+ Supernoteallemptyapkgfileproviderasynctasks
diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle
deleted file mode 100644
index 4faaea5d3f8e..000000000000
--- a/AnkiDroid/build.gradle
+++ /dev/null
@@ -1,855 +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"
- 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'
-
- // 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.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..ebcf51b3d8e0
--- /dev/null
+++ b/AnkiDroid/build.gradle.kts
@@ -0,0 +1,917 @@
+// 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")
+ id("ankidroid.plugins.robolectric-downloader")
+ 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")
+ 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 = 22600104
+ // 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.0alpha4"
+ testApplicationId = "com.ichi2.anki.tests"
+ vectorDrawables.useSupportLibrary = true
+ testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner"
+
+ // GA4 Measurement Protocol api_secret. Sourced from `local.properties`
+ // (ANALYTICS_API_KEY) or the ANALYTICS_API_KEY env var; falls back to
+ // 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"),
+ )
+ }
+ }
+}
+
+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.concurrent.futures.ktx)
+ implementation(libs.androidx.core)
+ 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.api)
+ 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)
+ // ankidroid.plugins.robolectric-downloader may need a new SDK jar entry
+ // when Robolectric or targetSdk changes. See the instructions in that plugin.
+ 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/proguard-test-rules.pro b/AnkiDroid/proguard-test-rules.pro
index 7ebaecba7990..001117b90228 100644
--- a/AnkiDroid/proguard-test-rules.pro
+++ b/AnkiDroid/proguard-test-rules.pro
@@ -7,6 +7,9 @@
-keep class kotlin.test.** { *; }
-keep class **.R$layout { (...); ; }
+# The platform ANR test asserts this method appears in Android's captured stack.
+-keep class com.ichi2.anki.testutil.AnrTestActivity { *; }
+
# Used by some test classes, not important for us
-dontwarn androidx.concurrent.futures.SuspendToFutureAdapter
diff --git a/AnkiDroid/robolectricDownloader.gradle b/AnkiDroid/robolectricDownloader.gradle
deleted file mode 100644
index 11bd88d9406e..000000000000
--- a/AnkiDroid/robolectricDownloader.gradle
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Downloads android-all-instrumented dependencies into Gradle's cache for offline Robolectric.
- * Writes a properties file pointing to the cached jars, so worktrees do not need their own copies.
- *
- * Once applied to your gradle project, can be executed with ./gradlew robolectricSdkDownload
- */
-
-// The general idea of this was borrowed from https://gist.github.com/xian/05c4f27da6d4156b9827842217c2cd5c
-// I then modified it heavily to allow easier addition of new SDK versions
-// The full implementation is from https://gist.github.com/simtel12/13ff3e57c37e78e468502b51ebb0f4f2
-
-// List from: https://github.com/robolectric/robolectric/blob/master/robolectric/src/main/java/org/robolectric/plugins/DefaultSdkProvider.java
-// This list will need to be updated for new Android SDK versions that come out.
-// Note: the PREINSTRUMENTED_VERSION constant is what you put on the end of the artifact
-
-// Only the versions currently used in AnkiDroid Robolectric tests are active, the rest are commented out
-// When updating Robolectric, match these versions to DefaultSdkProvider and PREINSTRUMENTED_VERSION.
-// Run `./gradlew jacocoUnitTestReport` to check that all SDKs used by the tests are included.
-def robolectricAndroidSdkVersions = [
-// [androidVersion: "6.0.1_r3", frameworkSdkBuildVersion: "r1"],
- [androidVersion: "7.0.0_r1", frameworkSdkBuildVersion: "r1"],
- [androidVersion: "7.1.0_r7", frameworkSdkBuildVersion: "r1"],
-// [androidVersion: "8.0.0_r4", frameworkSdkBuildVersion: "r1"],
- [androidVersion: "8.1.0", frameworkSdkBuildVersion: "4611349"],
- [androidVersion: "9", frameworkSdkBuildVersion: "4913185-2"],
- [androidVersion: "10", frameworkSdkBuildVersion: "5803371"],
- [androidVersion: "11", frameworkSdkBuildVersion: "6757853"],
- [androidVersion: "12", frameworkSdkBuildVersion: "7732740"],
- [androidVersion: "12.1", frameworkSdkBuildVersion: "8229987"],
- [androidVersion: "13", frameworkSdkBuildVersion: "9030017"],
- [androidVersion: "14", frameworkSdkBuildVersion: "10818077"],
- [androidVersion: "15", frameworkSdkBuildVersion: "13954326"],
- // TODO: check TarArchiveInputStream.forEachEntry when this is updated
- [androidVersion: "16", frameworkSdkBuildVersion: "13921718"], // current targetSdk
- [androidVersion: "17", frameworkSdkBuildVersion: "15733970"],
-]
-
-def robolectricSdkDownload = tasks.register('robolectricSdkDownload', WriteProperties) {
- group = "Dependencies"
- description = "Downloads Robolectric SDKs and writes their cached paths for offline tests"
- destinationFile = layout.buildDirectory.file("robolectric-deps.properties")
-}
-
-// Use separate configurations so Gradle retains every SDK version instead of selecting the newest.
-robolectricAndroidSdkVersions.forEach { robolectricSdkVersion ->
- // the final part of this `-i` comes from PREINSTRUMENTED_VERSION in upstream DefaultSdkProvider
- def version = "${robolectricSdkVersion['androidVersion']}-robolectric-${robolectricSdkVersion['frameworkSdkBuildVersion']}-i7"
-
- // Creating a configuration with a dependency allows Gradle to manage the actual resolution of
- // the jar file
- def sdkConfig = configurations.create(version)
- dependencies.add(version, "org.robolectric:android-all-instrumented:${version}")
-
- robolectricSdkDownload.configure {
- inputs.files(sdkConfig).withPropertyName(version)
- property("org.robolectric:android-all-instrumented:${version}",
- sdkConfig.elements.map { it.iterator().next().asFile.absolutePath })
- }
-}
diff --git a/AnkiDroid/src/androidTest/AndroidManifest.xml b/AnkiDroid/src/androidTest/AndroidManifest.xml
new file mode 100644
index 000000000000..8dba9251cda4
--- /dev/null
+++ b/AnkiDroid/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardViewerMathJaxTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardViewerMathJaxTest.kt
index 994c5c811495..3962916e4966 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardViewerMathJaxTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardViewerMathJaxTest.kt
@@ -47,7 +47,7 @@ class CardViewerMathJaxTest : InstrumentedTest() {
) {
ensureWebViewIsSupported()
val note = addNoteUsingBasicNoteType(front)
- val idsFile = IdsFile(testContext.cacheDir, listOf(note.firstCard(col).id))
+ val idsFile = IdsFile(testContext.cacheDir, listOf(note.firstCard(col).id), IdsFile.Purpose.PREVIEW)
val intent = PreviewerFragment.getIntent(testContext, idsFile, currentIndex = 0)
try {
ActivityScenario.launch(intent).use(block)
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentTest.kt
index 5efba55edd10..b471dcffb41d 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentTest.kt
@@ -11,6 +11,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.common.utils.ext.cardStateCustomizer
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.previewer.CardViewerActivity
@@ -23,7 +24,6 @@ import com.ichi2.anki.testutil.grantPermissions
import com.ichi2.anki.testutil.notificationPermission
import com.ichi2.anki.testutil.waitUntil
import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment
-import com.ichi2.anki.utils.ext.cardStateCustomizer
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.After
@@ -59,7 +59,7 @@ class ReviewerFragmentTest : InstrumentedTest() {
@After
fun tearDown() {
col.decks.remove(listOf(testDeckId))
- col.cardStateCustomizer = ""
+ col.config.cardStateCustomizer = ""
}
@Test
@@ -79,7 +79,7 @@ class ReviewerFragmentTest : InstrumentedTest() {
} else {
""
}
- col.cardStateCustomizer =
+ col.config.cardStateCustomizer =
"""
$delayJs
states.good.normal.review.easeFactor = 3.0;
@@ -121,7 +121,7 @@ class ReviewerFragmentTest : InstrumentedTest() {
@Test
fun testCustomSchedulerWithRuntimeError() {
// Issue 15035 - runtime errors weren't handled
- col.cardStateCustomizer = "states.this_is_not_defined.normal.review = 12;"
+ col.config.cardStateCustomizer = "states.this_is_not_defined.normal.review = 12;"
addCardToTestDeck()
withReviewer {
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
index 63546d125caa..8dec71fd40f1 100755
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
@@ -16,6 +16,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.common.utils.ext.cardStateCustomizer
import com.ichi2.anki.libanki.Consts
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.tests.InstrumentedTest
@@ -28,7 +29,6 @@ import com.ichi2.anki.testutil.grantPermissions
import com.ichi2.anki.testutil.notificationPermission
import com.ichi2.anki.testutil.useResumedActivity
import com.ichi2.anki.testutil.waitUntil
-import com.ichi2.anki.utils.ext.cardStateCustomizer
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.After
@@ -77,7 +77,7 @@ class ReviewerTest : InstrumentedTest() {
col.decks.select(Consts.DEFAULT_DECK_ID)
testDeckId = 0
}
- col.cardStateCustomizer = ""
+ col.config.cardStateCustomizer = ""
}
@Test
@@ -86,7 +86,7 @@ class ReviewerTest : InstrumentedTest() {
testDeckId = col.decks.addNormalDeckWithName(testDeckName).id
col.decks.select(testDeckId)
- col.cardStateCustomizer =
+ col.config.cardStateCustomizer =
"""
states.good.normal.review.easeFactor = 3.0;
states.good.normal.review.scheduledDays = 123;
@@ -134,7 +134,7 @@ class ReviewerTest : InstrumentedTest() {
col.decks.select(testDeckId)
// Issue 15035 - runtime errors weren't handled
- col.cardStateCustomizer = "states.this_is_not_defined.normal.review = 12;"
+ col.config.cardStateCustomizer = "states.this_is_not_defined.normal.review = 12;"
addNoteUsingBasicNoteType().firstCard(col).update { did = testDeckId }
closeGetStartedScreenIfExists()
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/analytics/AnrTraceIntegrationTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/analytics/AnrTraceIntegrationTest.kt
new file mode 100644
index 000000000000..8b8dc7be3c98
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/analytics/AnrTraceIntegrationTest.kt
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.analytics
+
+import android.os.Build
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import com.ichi2.anki.testutil.AnrTestActivity
+import com.ichi2.anki.testutil.captureAnrTrace
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/** Generates a real ANR to check the running OS's trace format. */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+class AnrTraceIntegrationTest {
+ @Test
+ fun decodesMainThreadFromNewPlatformAnr() {
+ val frames = captureAnrTrace().reader().use { it.readAnrMainThreadStackTrace() }
+
+ assertNotNull(frames, "Cannot decode the Android ${Build.VERSION.SDK_INT} ANR format")
+ assertTrue(
+ frames.any { it.className == AnrTestActivity::class.java.name && it.methodName == "deliberatelyBlockMainThread" },
+ "Decoded trace must contain the deliberate main-thread block: ${frames.contentToString()}",
+ )
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt
new file mode 100644
index 000000000000..8dd588fb39a0
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt
@@ -0,0 +1,208 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.cardviewer
+
+import android.text.InputType
+import android.view.inputmethod.EditorInfo
+import android.webkit.WebView
+import androidx.core.content.edit
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import com.ichi2.anki.R
+import com.ichi2.anki.Reviewer
+import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.libanki.DeckId
+import com.ichi2.anki.libanki.NoteTypeId
+import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.tests.checkWithTimeout
+import com.ichi2.anki.testutil.AvoidDayRolloverRule
+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.focusInput
+import com.ichi2.anki.testutil.grantPermissions
+import com.ichi2.anki.testutil.notificationPermission
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import java.util.UUID
+import kotlin.test.assertNotNull
+
+/** Checks the legacy reviewer's HTML, script loading, and WebView keyboard support together. */
+@RunWith(Parameterized::class)
+class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
+ @JvmField // required for Parameter
+ @Parameterized.Parameter
+ var autoFocus = false
+
+ @get:Rule
+ val keyboard = TestInputMethodRule()
+
+ @get:Rule
+ val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission)
+
+ @get:Rule
+ val avoidDayRollover = AvoidDayRolloverRule()
+
+ private var originalPreferences = emptyMap()
+ private var originalDeckId: DeckId = 0
+ private var originalNoteTypeId: NoteTypeId = 0
+ private var testDeckId: DeckId = 0
+ private val testNoteTypeIds = mutableListOf()
+
+ @Before
+ fun setUp() {
+ ensureWebViewIsSupported()
+ configurePreferences()
+ setUpCollection()
+ }
+
+ private fun configurePreferences() {
+ val preferences = testContext.sharedPrefs()
+ val newReviewerKey = testContext.getString(R.string.new_reviewer_options_key)
+ val doubleTapKey = testContext.getString(R.string.double_tap_timeout_pref_key)
+ originalPreferences =
+ listOf(newReviewerKey, "useInputTag", "autoFocusTypeInAnswer", doubleTapKey).associateWith { preferences.all[it] }
+ preferences.edit {
+ putBoolean(newReviewerKey, false)
+ putBoolean("useInputTag", true)
+ putBoolean("autoFocusTypeInAnswer", autoFocus)
+ // These tests intentionally grade immediately after Done.
+ putInt(doubleTapKey, 0)
+ }
+ }
+
+ private fun setUpCollection() {
+ originalDeckId = col.decks.selected()
+ originalNoteTypeId = col.notetypes.current(forDeck = false).id
+ testDeckId = col.decks.addNormalDeckWithName("HtmlTypeAnswerReviewerTest-${UUID.randomUUID()}").id
+ col.decks.select(testDeckId)
+ addAnswerCard("First", noSuggest = true)
+ }
+
+ private fun addAnswerCard(
+ front: String,
+ noSuggest: Boolean,
+ ) {
+ val noteType = col.notetypes.copy(assertNotNull(col.notetypes.byName("Basic (type in the answer)")))
+ testNoteTypeIds.add(noteType.id)
+ val filter = if (noSuggest) "nosuggest:type" else "type"
+ noteType.templates[0].qfmt = "{{Front}}{{$filter:Back}}"
+ col.notetypes.save(noteType)
+ val note =
+ col.newNote(noteType).apply {
+ setField(0, front)
+ setField(1, "été")
+ }
+ col.addNote(note, testDeckId)
+ }
+
+ @After
+ fun tearDown() {
+ restorePreferences()
+ restoreCollection()
+ }
+
+ private fun restorePreferences() {
+ testContext.sharedPrefs().edit {
+ for ((key, original) in originalPreferences) {
+ when (original) {
+ null -> remove(key)
+ is Boolean -> putBoolean(key, original)
+ is Int -> putInt(key, original)
+ else -> error("Unexpected preference value: $key=$original")
+ }
+ }
+ }
+ }
+
+ private fun restoreCollection() {
+ if (testDeckId != 0L) col.decks.remove(listOf(testDeckId))
+ for (id in testNoteTypeIds) col.notetypes.remove(id)
+ if (originalDeckId != 0L) col.decks.select(originalDeckId)
+ if (originalNoteTypeId != 0L) col.notetypes.setCurrent(assertNotNull(col.notetypes.get(originalNoteTypeId)))
+ }
+
+ @Test
+ fun onlyTheMarkedAnswerDisablesSuggestions() =
+ withReviewer { webView ->
+ webView.focusInput("ordinary")
+ val baseline = keyboard.awaitEditor().inputType
+ webView.focusInput("typeans")
+ keyboard.awaitEditor(InputType.TYPE_NULL, imeAction = EditorInfo.IME_ACTION_DONE)
+ webView.focusInput("ordinary")
+ keyboard.awaitEditor(baseline)
+ }
+
+ @Test
+ fun doneShowsTheAnswerWithTheTextSentByTheIme() =
+ withReviewer { webView ->
+ focusAnswer(webView, noSuggest = true)
+ keyboard.perform("chars", "été")
+ webView.awaitJavascript("document.getElementById('typeans').value === 'été'")
+ keyboard.perform("done")
+ webView.awaitJavascript("document.querySelector('#typeans .typeGood')?.textContent === 'été'")
+ }
+
+ @Test
+ fun suggestionsFollowActualCardTransitions() {
+ addAnswerCard("Second", noSuggest = false)
+ addAnswerCard("Third", noSuggest = true)
+ withReviewer { webView ->
+ for ((question, noSuggest) in listOf("First" to true, "Second" to false, "Third" to true)) {
+ webView.awaitJavascript("document.getElementById('question')?.textContent === '$question'")
+ focusAnswer(webView, noSuggest)
+ keyboard.perform("commit", "été")
+ webView.awaitJavascript("document.getElementById('typeans').value === 'été'")
+ keyboard.perform("done")
+ webView.awaitJavascript("document.querySelector('#typeans .typeGood')?.textContent === 'été'")
+ if (question != "Third") {
+ onView(withId(R.id.flashcard_layout_ease3))
+ .also { it.checkWithTimeout(matches(isDisplayed())) }
+ .perform(click())
+ }
+ }
+ }
+ }
+
+ private fun focusAnswer(
+ webView: WebView,
+ noSuggest: Boolean,
+ ) {
+ if (autoFocus) {
+ webView.awaitJavascript("document.activeElement?.id === 'typeans'")
+ } else {
+ webView.focusInput("typeans")
+ }
+ if (noSuggest) {
+ keyboard.awaitEditor(InputType.TYPE_NULL, imeAction = EditorInfo.IME_ACTION_DONE)
+ } else {
+ keyboard.awaitEditor()
+ }
+ }
+
+ private fun withReviewer(block: (WebView) -> Unit) {
+ ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario ->
+ lateinit var webView: WebView
+ scenario.onActivity { activity ->
+ webView = assertNotNull(activity.webView)
+ }
+ webView.awaitJavascript("document.getElementById('typeans') !== null")
+ block(webView)
+ }
+ }
+
+ companion object {
+ @JvmStatic // required for Parameters
+ @Parameterized.Parameters(name = "autoFocus={0}")
+ fun focusModes() = listOf(false, true)
+ }
+}
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/ACRATest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ACRATest.kt
index a414f17acfbe..85089e9713fb 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ACRATest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ACRATest.kt
@@ -20,6 +20,7 @@ import com.ichi2.anki.common.crashreporting.CrashReporter.Companion.FEEDBACK_REP
import com.ichi2.anki.common.crashreporting.CrashReporter.Companion.FEEDBACK_REPORT_ASK
import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.logging.ProductionCrashReportingTree
+import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.servicelayer.ThrowableFilterService
import com.ichi2.anki.setDebugACRAConfig
import com.ichi2.anki.setProductionACRAConfig
@@ -56,6 +57,7 @@ class ACRATest : InstrumentedTest() {
fun setUp() {
app = testContext.applicationContext as AnkiDroidApp
// Note: attachBaseContext can't be called twice as we're using the same instance between all tests.
+ ChangeManager.unsubscribe(app!!)
app!!.onCreate()
}
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/tests/LayoutValidationTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/LayoutValidationTest.kt
index 352f597d45b4..5765d6920fed 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/LayoutValidationTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/LayoutValidationTest.kt
@@ -97,7 +97,7 @@ class LayoutValidationTest : InstrumentedTest() {
com.ichi2.anki.R.layout.fragment_card_browser_searchview,
) +
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
- listOf(com.ichi2.anki.R.layout.widget_small_unthemed)
+ listOf(com.ichi2.anki.widgets.R.layout.widget_small_unthemed)
} else {
emptyList()
}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/NotificationChannelTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/NotificationChannelTest.kt
index 108ddfb44e00..640fd82cee35 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/NotificationChannelTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/NotificationChannelTest.kt
@@ -13,6 +13,7 @@ import com.ichi2.anki.AnkiDroidApp
import com.ichi2.anki.NotificationChannel
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion
+import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.testutil.GrantStoragePermission
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.greaterThanOrEqualTo
@@ -39,7 +40,9 @@ class NotificationChannelTest : InstrumentedTest() {
@UiThreadTest
fun setUp() {
val targetContext = testContext
- (targetContext.applicationContext as AnkiDroidApp).onCreate()
+ val app = targetContext.applicationContext as AnkiDroidApp
+ ChangeManager.unsubscribe(app)
+ app.onCreate()
currentAPI = sdkVersion
targetAPI = targetContext.applicationInfo.targetSdkVersion
manager = targetContext.getSystemService()!!
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AnrTestActivity.java b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AnrTestActivity.java
new file mode 100644
index 000000000000..c0aa4d78ab21
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AnrTestActivity.java
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.testutil;
+
+import android.app.Activity;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Process;
+import android.os.ResultReceiver;
+import android.os.SystemClock;
+import android.widget.TextView;
+
+import androidx.annotation.RequiresApi;
+
+/**
+ * Test activity producing an ANR, launched in a new process.
+ * Runs from the test APK (with no Kotlin runtime). This is Java to avoid adding dependencies.
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+public class AnrTestActivity extends Activity {
+ private boolean blocking;
+
+ @Override
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ setShowWhenLocked(true);
+ setTurnScreenOn(true);
+ TextView text = new TextView(this);
+ text.setText("Waiting for the ANR trace regression test");
+ setContentView(text);
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ if (hasFocus && !blocking) {
+ blocking = true;
+ getWindow().getDecorView().post(this::deliberatelyBlockMainThread);
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private void deliberatelyBlockMainThread() {
+ Bundle ready = new Bundle();
+ ready.putInt("pid", Process.myPid());
+ ready.putParcelable("stop", new ResultReceiver(null) {
+ @Override
+ protected void onReceiveResult(int resultCode, Bundle resultData) {
+ // Runs on a Binder thread, so cleanup works while the main thread is blocked.
+ Process.killProcess(Process.myPid());
+ }
+ });
+ ResultReceiver receiver = getIntent().getParcelableExtra("ready");
+ receiver.send(0, ready);
+ // Bound the block and clean up even if the test runner disappears.
+ SystemClock.sleep(120_000);
+ Process.killProcess(Process.myPid());
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AnrTraceCapture.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AnrTraceCapture.kt
new file mode 100644
index 000000000000..82ae36e731ff
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AnrTraceCapture.kt
@@ -0,0 +1,125 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.testutil
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.ActivityManager
+import android.app.ApplicationExitInfo
+import android.app.Instrumentation
+import android.app.UiAutomation
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.ResultReceiver
+import android.os.SystemClock
+import android.provider.Settings
+import android.view.KeyEvent
+import androidx.annotation.RequiresApi
+import androidx.core.os.BundleCompat
+import androidx.test.platform.app.InstrumentationRegistry
+import java.io.InputStream
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+private const val HIDE_ERROR_DIALOGS = "hide_error_dialogs"
+
+/** Causes an ANR in the test APK's separate process and opens its trace. The caller must close the stream. */
+@RequiresApi(Build.VERSION_CODES.R)
+fun captureAnrTrace(): InputStream {
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ val automation = instrumentation.uiAutomation
+ val resolver = instrumentation.targetContext.contentResolver
+ val previousHideDialogs = Settings.Global.getString(resolver, HIDE_ERROR_DIALOGS)
+ return automation.withShellPermissions(Manifest.permission.DUMP, Manifest.permission.WRITE_SECURE_SETTINGS) {
+ try {
+ // Let Android close the unresponsive process without requiring a dialog interaction.
+ assertTrue(Settings.Global.putInt(resolver, HIDE_ERROR_DIALOGS, 1), "Cannot hide ANR dialogs")
+ instrumentation.withAnrActivity { pid, startedAt ->
+ automation.injectAnrInput()
+ val activityManager = instrumentation.targetContext.getSystemService(ActivityManager::class.java)
+ val anr = activityManager.awaitAnrExit(instrumentation.context.packageName, pid, startedAt)
+ assertNotNull(anr.traceInputStream, "Android recorded an ANR without a trace")
+ }
+ } finally {
+ assertTrue(Settings.Global.putString(resolver, HIDE_ERROR_DIALOGS, previousHideDialogs), "Cannot restore ANR dialogs")
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.Q)
+private fun UiAutomation.withShellPermissions(
+ vararg permissions: String,
+ block: () -> T,
+): T {
+ adoptShellPermissionIdentity(*permissions)
+ try {
+ return block()
+ } finally {
+ dropShellPermissionIdentity()
+ }
+}
+
+/** Waits for the activity's focused window before running [block], and stops its process on failure. */
+@RequiresApi(Build.VERSION_CODES.R)
+private fun Instrumentation.withAnrActivity(block: (pid: Int, startedAt: Long) -> T): T {
+ val ready = ArrayBlockingQueue(1)
+ val receiver =
+ object : ResultReceiver(null) {
+ override fun onReceiveResult(
+ resultCode: Int,
+ resultData: Bundle,
+ ) {
+ ready.offer(resultData)
+ }
+ }
+
+ // Android timestamps these exits using the real wall clock.
+ @SuppressLint("DirectSystemCurrentTimeMillisUsage")
+ val startedAt = System.currentTimeMillis()
+ context.startActivity(
+ Intent()
+ .setClassName(context.packageName, AnrTestActivity::class.java.name)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .putExtra("ready", receiver),
+ )
+ val probe = assertNotNull(ready.poll(20, TimeUnit.SECONDS), "ANR activity never gained focus")
+ probe.classLoader = AnrTestActivity::class.java.classLoader
+ val stop = assertNotNull(BundleCompat.getParcelable(probe, "stop", ResultReceiver::class.java))
+ try {
+ return block(probe.getInt("pid"), startedAt)
+ } finally {
+ stop.send(0, Bundle())
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.R)
+private fun ActivityManager.awaitAnrExit(
+ testPackage: String,
+ pid: Int,
+ startedAt: Long,
+): ApplicationExitInfo {
+ var exit: ApplicationExitInfo? = null
+ waitUntil(60.seconds, message = { "Android did not record an ANR for test PID $pid; latest exit: $exit" }) {
+ exit = getHistoricalProcessExitReasons(testPackage, pid, 1).firstOrNull()
+ exit != null
+ }
+ return assertNotNull(exit).also {
+ assertEquals(ApplicationExitInfo.REASON_ANR, it.reason)
+ assertEquals("$testPackage:anr_trace_test", it.processName)
+ assertTrue(it.timestamp >= startedAt, "Must inspect this run's ANR, not an older exit")
+ }
+}
+
+private fun UiAutomation.injectAnrInput() {
+ val now = SystemClock.uptimeMillis()
+ for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
+ val event = KeyEvent(now, now, action, KeyEvent.KEYCODE_DPAD_CENTER, 0)
+ // Waiting for delivery would hang behind the intentionally blocked main thread.
+ assertTrue(injectInputEvent(event, false), "Failed to inject ANR-triggering input")
+ }
+}
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..9269fbe54bf4
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethod.java
@@ -0,0 +1,130 @@
+// 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;
+ // EditorInfo can outlive its connection while Chromium restarts input.
+ // Probe immediately before each action, as well as when awaiting metadata.
+ // Returning before dispatch lets the caller safely retry without typing twice.
+ if (connection.getTextBeforeCursor(0, 0) == null) return result;
+ result.putBoolean("ready", true);
+ 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..8600419ea958
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/TestInputMethodRule.kt
@@ -0,0 +1,141 @@
+// 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 callIme(
+ action: String,
+ text: String? = null,
+ ): Bundle? = instrumentation.context.contentResolver.call(uri, action, text, null)
+
+ /** Wait for a responsive input connection and matching metadata in the same IME snapshot. */
+ fun awaitEditor(
+ inputType: Int? = null,
+ imeAction: Int? = null,
+ ): EditorInfo {
+ var response = ImeResponse()
+ waitUntil(message = {
+ "IME expected inputType=$inputType, action=$imeAction; received ${response.describeEditor()}"
+ }) {
+ response = ImeResponse(assertNotNull(callIme("editor")))
+ response.ready && response.editor?.matches(inputType, imeAction) == true
+ }
+ return assertNotNull(response.editor)
+ }
+
+ fun perform(
+ action: String,
+ text: String? = null,
+ ) {
+ var response = ImeResponse()
+ waitUntil(message = { "IME connection did not become ready for $action" }) {
+ response = ImeResponse(callIme(action, text))
+ response.ready
+ }
+ assertTrue(response.accepted, "IME rejected $action")
+ }
+
+ private fun EditorInfo.matches(
+ inputType: Int?,
+ imeAction: Int?,
+ ): Boolean =
+ packageName == instrumentation.targetContext.packageName && matchesInputType(inputType) &&
+ (imeAction == null || imeOptions and EditorInfo.IME_MASK_ACTION == imeAction)
+
+ private fun EditorInfo.matchesInputType(expected: Int?): Boolean =
+ if (expected == null) {
+ inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_CLASS_TEXT &&
+ inputType and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT
+ } else {
+ inputType == expected
+ }
+
+ /** Metadata and readiness must come from the same call to the IME. */
+ private class ImeResponse(
+ bundle: Bundle? = null,
+ ) {
+ val editor = bundle?.let { BundleCompat.getParcelable(it, "editor", EditorInfo::class.java) }
+ val ready = bundle?.getBoolean("ready") == true
+ val accepted = bundle?.getBoolean("accepted") == true
+
+ fun describeEditor(): String {
+ val action = editor?.imeOptions?.and(EditorInfo.IME_MASK_ACTION)
+ return "inputType=${editor?.inputType}, action=$action, package=${editor?.packageName}, ready=$ready"
+ }
+ }
+}
+
+// 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/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml
index 97ed6a7ac06e..f5c3dc5cc208 100644
--- a/AnkiDroid/src/main/AndroidManifest.xml
+++ b/AnkiDroid/src/main/AndroidManifest.xml
@@ -539,21 +539,6 @@
android:name="com.ichi2.anki.receiver.CopyToClipboardReceiver"
android:exported="false" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/AnkiDroid/src/main/assets/ankidroid.css b/AnkiDroid/src/main/assets/ankidroid.css
index da3dc749c4f1..f7e8efd7d1fc 100644
--- a/AnkiDroid/src/main/assets/ankidroid.css
+++ b/AnkiDroid/src/main/assets/ankidroid.css
@@ -7,6 +7,11 @@ img {
max-height: 100%;
}
+/* Avoid reserving text descender space below the audio icon inside its span. */
+.replay-button svg {
+ display: block;
+}
+
@font-face {
font-family: "Noto Sans Mono";
src: url("file:///android_asset/fonts/noto/NotoSansMono.ttf")
diff --git a/AnkiDroid/src/main/assets/card_template.html b/AnkiDroid/src/main/assets/card_template.html
index dc48e11772b7..3b50f1778e59 100644
--- a/AnkiDroid/src/main/assets/card_template.html
+++ b/AnkiDroid/src/main/assets/card_template.html
@@ -12,6 +12,7 @@
::script::
+
diff --git a/AnkiDroid/src/main/assets/scripts/card.js b/AnkiDroid/src/main/assets/scripts/card.js
index 694a9aae4d81..234ae2a4924f 100644
--- a/AnkiDroid/src/main/assets/scripts/card.js
+++ b/AnkiDroid/src/main/assets/scripts/card.js
@@ -188,7 +188,7 @@ var onPageFinished = function () {
.then(() => card.classList.add("mathjax-rendered"))
.then(() => {
// Focus if the element contains the attribute
- if (typedElement && typedElement.getAttribute("data-focus")) {
+ if (typedElement && typedElement.getAttribute("data-focus") === "true") {
typedElement.focus();
}
})
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/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
index fe5297aa892d..d3f2effea34e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
@@ -93,6 +93,7 @@ import com.ichi2.anki.cardviewer.SingleCardSide
import com.ichi2.anki.cardviewer.TTS
import com.ichi2.anki.cardviewer.TypeAnswer
import com.ichi2.anki.cardviewer.TypeAnswer.Companion.createInstance
+import com.ichi2.anki.cardviewer.TypeAnswerWebView
import com.ichi2.anki.cardviewer.ViewerCommand
import com.ichi2.anki.cardviewer.ViewerRefresh
import com.ichi2.anki.cardviewer.handledGamepadKeyDown
@@ -109,6 +110,7 @@ import com.ichi2.anki.common.utils.HashUtil.hashSetInit
import com.ichi2.anki.common.utils.android.HandlerUtils.newHandler
import com.ichi2.anki.common.utils.android.getResFromAttr
import com.ichi2.anki.common.utils.android.showThemedToast
+import com.ichi2.anki.common.utils.ext.showIntervalsOnButtons
import com.ichi2.anki.compat.CompatHelper.Companion.resolveActivityCompat
import com.ichi2.anki.compat.ResolveInfoFlagsCompat
import com.ichi2.anki.dialogs.TtsPlaybackErrorDialog
@@ -1270,7 +1272,7 @@ abstract class AbstractFlashcardViewer :
// These are preferences we pull out of the collection instead of SharedPreferences
try {
lifecycle.removeObserver(automaticAnswer)
- showNextReviewTime = col.config.get("estTimes") ?: true
+ showNextReviewTime = col.config.showIntervalsOnButtons
automaticAnswer = AutomaticAnswer.createInstance(this, col)
lifecycle.addObserver(automaticAnswer)
} catch (ex: Exception) {
@@ -2015,7 +2017,7 @@ abstract class AbstractFlashcardViewer :
/** Fixing bug 720: focus, thanks to pablomouzo on android issue 7189 */
internal inner class MyWebView(
context: Context?,
- ) : WebView(context!!) {
+ ) : TypeAnswerWebView(context!!) {
override fun loadDataWithBaseURL(
baseUrl: String?,
data: String,
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/AcraCrashReporter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AcraCrashReporter.kt
index 69d91f379104..3478b7999d27 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AcraCrashReporter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AcraCrashReporter.kt
@@ -277,6 +277,8 @@ private object AcraCrashReporter : CrashReporter {
.sharedPrefs()
.getString(FEEDBACK_REPORT_KEY, FEEDBACK_REPORT_ASK)
if (onlyIfSilent) {
+ // NOTE: handleException below still runs ACRA's toast interaction
+ // only submits if the consent dialog is skippable
if (FEEDBACK_REPORT_ALWAYS != reportMode) {
Timber.i("sendExceptionReport - onlyIfSilent true, but ACRA is not 'always accept'. Skipping report send.")
return
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt
index 3c9ed3d2bfda..f300040ba594 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt
@@ -268,7 +268,7 @@ sealed class AndroidTtsError : TtsPlayer.TtsError() {
is AndroidGenericError -> "ERROR"
is AndroidSynthesisError -> "ERROR_SYNTHESIS"
is AndroidInvalidRequest -> "ERROR_INVALID_REQUEST"
- is AndroidNetworkError -> "ERROR_NETWORK_ERROR"
+ is AndroidNetworkError -> "ERROR_NETWORK"
is AndroidNetworkTimeoutError -> "ERROR_NETWORK_TIMEOUT"
is AndroidNotInstalledYet -> "ERROR_NOT_INSTALLED_YET"
is AndroidOutputError -> "ERROR_OUTPUT"
@@ -288,6 +288,7 @@ sealed class AndroidTtsError : TtsPlayer.TtsError() {
TextToSpeech.ERROR_SYNTHESIS -> AndroidSynthesisError
TextToSpeech.ERROR_INVALID_REQUEST -> AndroidInvalidRequest
TextToSpeech.ERROR_NETWORK -> AndroidNetworkError
+ TextToSpeech.ERROR_NETWORK_TIMEOUT -> AndroidNetworkTimeoutError
TextToSpeech.ERROR_OUTPUT -> AndroidOutputError
TextToSpeech.ERROR_NOT_INSTALLED_YET -> AndroidNotInstalledYet
TextToSpeech.ERROR_SERVICE -> AndroidServiceError
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt
index b9577f959eb4..65f4a46e8656 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt
@@ -48,9 +48,12 @@ import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
+import androidx.lifecycle.DEFAULT_ARGS_KEY
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
+import androidx.lifecycle.viewmodel.CreationExtras
+import androidx.lifecycle.viewmodel.MutableCreationExtras
import androidx.viewbinding.ViewBinding
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
@@ -91,6 +94,7 @@ import com.ichi2.anki.libanki.Collection
import com.ichi2.anki.receiver.SdCardReceiver
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.snackbar.showSnackbar
+import com.ichi2.anki.utils.ViewModelSavedStateHandle
import com.ichi2.anki.utils.ext.requireString
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.anki.workarounds.AppLoadedFromBackupWorkaround.showedActivityFailedScreen
@@ -117,6 +121,17 @@ open class AnkiActivity(
AnkiActivityProvider {
val exportReadyViewModel by viewModels { ExportReadyViewModel.factory }
+ /**
+ * Supplies [ViewModelSavedStateHandle] without copying intent extras into every handle.
+ * Launch inputs belong in ViewModel constructor parameters.
+ * This also applies to fragments using activityViewModels(), but not fragment-scoped ViewModels.
+ */
+ override val defaultViewModelCreationExtras: CreationExtras
+ get() =
+ MutableCreationExtras(super.defaultViewModelCreationExtras).apply {
+ this[DEFAULT_ARGS_KEY] = Bundle.EMPTY
+ }
+
/**
* Receiver that informs us when a broadcast listen in [broadcastsActions] is received.
*
@@ -130,7 +145,7 @@ open class AnkiActivity(
val dialogHandler = DialogHandler(this)
override val ankiActivity = this
- private val customTabActivityHelper: CustomTabActivityHelper = CustomTabActivityHelper()
+ private val customTabActivityHelper: CustomTabActivityHelper = CustomTabActivityHelper(lifecycleScope)
private lateinit var fileExportPath: String
private val saveFileLauncher: ActivityResultLauncher =
@@ -451,15 +466,14 @@ open class AnkiActivity(
}
}
+ /**
+ * Preloads a URL for future loading.
+ *
+ * @see CustomTabActivityHelper.mayLaunchUrl
+ */
internal fun mayOpenUrl(
@StringRes url: Int,
- ) {
- val url = getString(url)
- val success = customTabActivityHelper.mayLaunchUrl(url.toUri(), null, null)
- if (!success) {
- Timber.w("Couldn't preload url: %s", url)
- }
- }
+ ) = customTabActivityHelper.mayLaunchUrl(getString(url))
/**
* Opens a URL in a custom tab, with fallback to a browser if no custom tab implementation is available.
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
index ade3addae50e..1462c01fcb63 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
@@ -24,6 +24,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
import anki.collection.OpChanges
import com.ichi2.anki.AnkiDroidApp.Companion.sharedPreferencesTestingOverride
import com.ichi2.anki.analytics.initializeAnalytics
+import com.ichi2.anki.analytics.launchPreviousAnrReporting
import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository
import com.ichi2.anki.common.android.AdaptionUtil
import com.ichi2.anki.common.android.Animations
@@ -43,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
@@ -53,9 +55,11 @@ 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
+import com.ichi2.anki.recorder.VoiceRecorder
import com.ichi2.anki.reviewreminders.ReminderLogTree
import com.ichi2.anki.servicelayer.DebugInfoService
import com.ichi2.anki.servicelayer.ThrowableFilterService
@@ -67,7 +71,6 @@ import com.ichi2.anki.startup.ensureCollectionPathSet
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs
import com.ichi2.anki.widget.RECURRING_WIDGETS
-import com.ichi2.utils.AlarmManagement
import com.ichi2.utils.ExceptionUtil
import com.ichi2.utils.LanguageUtil
import com.ichi2.utils.measureTime
@@ -127,7 +130,8 @@ open class AnkiDroidApp :
}
}
- private var profileManager: ProfileManager? = null
+ var profileManager: ProfileManager? = null
+ private set
private val profileContext: Context?
get() = profileManager?.activeProfileContext
@@ -139,7 +143,7 @@ open class AnkiDroidApp :
*/
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
- if (isAcraSenderProcess()) return
+ if (isAcraSenderProcess() || isPhoenixProcess()) return
profileManager = ProfileManager.createOrNull(base)
}
@@ -169,6 +173,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()) {
@@ -182,6 +190,10 @@ open class AnkiDroidApp :
initializeWidgetRepository()
WidgetNotificationScheduler.register { scheduleNotification() }
Animations.setPreferencesProvider { context -> PrefsRepository(context) }
+ CollectionLockedException.messageProvider = {
+ getString(CommonString.database_locked_summary_new, getString(CommonString.col_path))
+ }
+ ChangeManager.throwOnDuplicateSubscription = BuildConfig.DEBUG
val logType = LogType.value
when (logType) {
LogType.DEBUG -> Timber.plant(DebugTree())
@@ -213,6 +225,7 @@ open class AnkiDroidApp :
Timber.d("Skipping AnkiDroidApp.onCreate from ACRA sender process")
return
}
+ launchPreviousAnrReporting()
ProfileManager.attachError?.let {
Timber.w(it, "Failed to load the profile environment, running on the base context")
}
@@ -267,6 +280,7 @@ open class AnkiDroidApp :
runCatching {
measureTime("cacheCleanup") {
MultimediaArgsStorage.create(this@AnkiDroidApp).removeExpiredFiles()
+ VoiceRecorder.removeExpiredRecordings(VoiceRecorder.recordingsDirectory(this@AnkiDroidApp))
}
}.onFailure { Timber.w(it, "Unable to clean up expired cache files") }
}
@@ -398,13 +412,13 @@ open class AnkiDroidApp :
setupNotificationChannels(applicationContext)
val context = this.withAppLocale()
- // In the future, if more notifications are added to AnkiDroid, AlarmManagement.scheduleAllNotifications
+ // In the future, if more notifications are added to AnkiDroid, scheduleAllNotifications
// should be extended to handle them, but since for now the only modernized notifications are review reminder
// notifications, we block this behind the review-reminder-specific feature flag.
if (Prefs.newReviewRemindersEnabled) {
Timber.i("Setting notifications if they have not already been set")
applicationScope.launch {
- AlarmManagement.scheduleAllNotifications(context)
+ scheduleAllNotifications(context)
}
} else {
// Register for notifications
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPIConstants.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPIConstants.kt
index 119e01a0121b..9dda5117caab 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPIConstants.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPIConstants.kt
@@ -1,19 +1,5 @@
-/*
- * Copyright (c) 2020 Mani infinyte01@gmail.com
- *
- * 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 http://www.gnu.org/licenses/>.
- *
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2020 Mani infinyte01@gmail.com
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
index 800b64a6351c..351c1e01a9ee 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2022 Ankitects Pty Ltd
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2022 Ankitects Pty Ltd
package com.ichi2.anki
@@ -21,8 +8,11 @@ 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
+import kotlin.time.Duration.Companion.seconds
/**
* Attempts a backup and awaits its completion.
@@ -37,7 +27,7 @@ import net.ankiweb.rsdroid.BackendException
suspend fun performBackupInBackground(force: Boolean = false): Boolean {
// Wait a second to allow the deck list to finish loading first, or it
// will hang until the first stage of the backup completes.
- delay(1000)
+ delay(1.seconds)
return createBackup(force = force)
}
@@ -57,7 +47,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/BackupManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackupManager.kt
index b48ba7cf538e..c379a1e56a20 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/BackupManager.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackupManager.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2011 Norbert Nagold
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2011 Norbert Nagold
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
index 5b216f5b8424..e25b9b46ff09 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
@@ -45,6 +45,7 @@ import com.ichi2.anki.browser.CardBrowserViewModel.SearchState
import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Initializing
import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Searching
import com.ichi2.anki.browser.CardOrNoteId
+import com.ichi2.anki.browser.IdsFile
import com.ichi2.anki.browser.SaveSearchResult
import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository
import com.ichi2.anki.browser.registerFindReplaceHandler
@@ -57,6 +58,7 @@ import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.destinations.NoteEditorDestination
import com.ichi2.anki.common.destinations.navigate
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
+import com.ichi2.anki.common.utils.ext.getLongExtra
import com.ichi2.anki.common.utils.ext.onPermanentDismissal
import com.ichi2.anki.databinding.ActivityCardBrowserBinding
import com.ichi2.anki.dialogs.DiscardChangesDialog
@@ -219,11 +221,11 @@ open class CardBrowser :
ResizablePaneManager(
parentLayout = requireNotNull(binding.cardBrowserXlView) { "cardBrowserXlView" },
divider = requireNotNull(binding.cardBrowserResizingDivider) { "cardBrowserResizingDivider" },
- leftPane = requireNotNull(binding.cardBrowserFrame) { "cardBrowserFrame" },
- rightPane = requireNotNull(binding.noteEditorFrame) { "noteEditorFrame" },
+ startPane = requireNotNull(binding.cardBrowserFrame) { "cardBrowserFrame" },
+ endPane = requireNotNull(binding.noteEditorFrame) { "noteEditorFrame" },
sharedPrefs = Prefs.getUiConfig(this),
- leftPaneWeightKey = PREF_CARD_BROWSER_PANE_WEIGHT,
- rightPaneWeightKey = PREF_NOTE_EDITOR_PANE_WEIGHT,
+ startPaneWeightKey = PREF_CARD_BROWSER_PANE_WEIGHT,
+ endPaneWeightKey = PREF_NOTE_EDITOR_PANE_WEIGHT,
)
} else {
binding.noteEditorFrame?.isVisible = false
@@ -646,9 +648,10 @@ open class CardBrowser :
viewModelStore,
CardBrowserViewModel.factory(
lastDeckIdRepository = AnkiDroidApp.instance.sharedPrefsLastDeckIdRepository,
- cacheDir = cacheDir,
+ idsFileDirectory = IdsFile.getDirectory(this),
options = launchOptions,
isFragmented = fragmented,
+ initialDeckId = intent?.getLongExtra(CardBrowserViewModel.EXTRA_DECK_ID),
),
defaultViewModelCreationExtras,
)[CardBrowserViewModel::class.java]
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateChanges.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateChanges.kt
new file mode 100644
index 000000000000..92d86f8d8d5f
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateChanges.kt
@@ -0,0 +1,286 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import com.ichi2.anki.CardTemplateNotetype.ChangeType
+import com.ichi2.anki.CardTemplateNotetype.TemplateChange
+import timber.log.Timber
+
+/** Tracks unsaved template operations and translates editor positions to database ordinals. */
+class CardTemplateChanges {
+ var templateChanges = ArrayList()
+
+ /**
+ * Template deletes shift card ordinals in the database. To operate without saving, we must keep track to apply in order.
+ * In addition, we don't want to persist a template add just to delete it later, so we combine those if they happen
+ */
+ fun addTemplateChange(
+ type: ChangeType,
+ ordinal: Int,
+ ) {
+ Timber.d("addTemplateChange() type %s for ordinal %s", type, ordinal)
+ val templateChanges = templateChanges
+ val change = TemplateChange(ordinal, type)
+
+ // If we are deleting something we added but have not saved, edit it out of the change list
+ if (type == ChangeType.DELETE) {
+ var ordinalAdjustment = 0
+ for (i in templateChanges.indices.reversed()) {
+ val oldChange = templateChanges[i]
+ when (oldChange.type) {
+ ChangeType.DELETE ->
+ if (oldChange.ordinal - ordinalAdjustment <= ordinal) {
+ // Deleting an ordinal at or below us? Adjust our comparison basis...
+ ordinalAdjustment++
+ continue
+ }
+ ChangeType.ADD ->
+ if (ordinal == oldChange.ordinal - ordinalAdjustment) {
+ // Deleting something we added this session? Edit it out via compaction
+ compactTemplateChanges(oldChange.ordinal)
+ return
+ }
+ }
+ }
+ }
+ Timber.d("addTemplateChange() added ord/type: %s/%s", change.ordinal, change.type)
+ templateChanges.add(change)
+ dumpChanges()
+ }
+
+ /**
+ * Return an int[] containing the collection-relative ordinals of all the currently pending deletes,
+ * including the ordinal passed in, as opposed to the changelist-relative ordinals
+ *
+ * @param ord int UI-relative ordinal to check database for delete safety along with existing deletes
+ * @return int[] of all ordinals currently in the database, pending delete
+ */
+ fun getDeleteDbOrds(ord: Int): IntArray {
+ dumpChanges()
+ Timber.d("getDeleteDbOrds()")
+
+ // array containing the original / db-relative ordinals for all pending deletes plus the proposed one
+ val deletedDbOrds = ArrayList(templateChanges.size)
+
+ // For each entry in the changes list - and the proposed delete - scan for deletes to get original ordinal
+ for (i in 0..templateChanges.size) {
+ var ordinalAdjustment = 0
+
+ // We need an initializer. Though proposed change is checked last, it's a reasonable default initializer.
+ var currentChange = TemplateChange(ord, ChangeType.DELETE)
+ if (i < templateChanges.size) {
+ // Until we exhaust the pending change list we will use them
+ currentChange = templateChanges[i]
+ }
+
+ // If the current pending change isn't a delete, it is unimportant here
+ if (currentChange.type !== ChangeType.DELETE) {
+ continue
+ }
+
+ // If it is a delete, scan previous deletes and shift as necessary for original ord
+ for (j in 0 until i) {
+ val previousChange = templateChanges[j]
+
+ // Is previous change a delete? Lower ordinal than current change?
+ if (previousChange.type === ChangeType.DELETE && previousChange.ordinal <= currentChange.ordinal) {
+ // If so, that is the case where things shift. It means our ordinals moved and original ord is higher
+ ordinalAdjustment++
+ }
+ }
+
+ // We know how many times ordinals smaller than the current were deleted so we have the total adjustment
+ // Save this pending delete at it's original / db-relative position
+ deletedDbOrds.add(currentChange.ordinal + ordinalAdjustment)
+ }
+ val deletedDbOrdInts = IntArray(deletedDbOrds.size)
+ for (i in deletedDbOrdInts.indices) {
+ deletedDbOrdInts[i] = deletedDbOrds[i]
+ }
+ return deletedDbOrdInts
+ }
+
+ fun dumpChanges() {
+ if (!BuildConfig.DEBUG) {
+ return
+ }
+ val adjustedChanges = adjustedTemplateChanges
+ for (i in templateChanges.indices) {
+ val change = templateChanges[i]
+ val adjustedChange = adjustedChanges[i]
+ Timber.d("dumpChanges() Change %s is ord/type %s/%s", i, change.ordinal, change.type)
+ Timber.d(
+ "dumpChanges() During save change %s will be ord/type %s/%s",
+ i,
+ adjustedChange.ordinal,
+ adjustedChange.type,
+ )
+ }
+ }
+
+ /**
+ * Adjust the ordinals in our accrued change list so that any pending adds have the correct
+ * ordinal after taking into account any pending deletes
+ *
+ * @return ArrayList[2]> of [ordinal][ChangeType] entries
+ */
+ val adjustedTemplateChanges: ArrayList
+ get() {
+ val changes = templateChanges
+ val adjustedChanges = ArrayList(changes.size)
+
+ // In order to save the changes into the database, the ordinals in the changelist must correspond to the
+ // ordinals in the database (for deletes) or the correct index in the changes array (for adds)
+ // It is not possible to know what those will be until the user requests a save, so they are stored in the
+ // change list as-is until the save time comes, then the adjustment is made all at once
+ for (i in changes.indices) {
+ val change = changes[i]
+ val adjustedChange =
+ when (change.type) {
+ ChangeType.ADD -> {
+ val adjustedOrdinal = getAdjustedAddOrdinalAtChangeIndex(this, i)
+ Timber.d(
+ "getAdjustedTemplateChanges() change %s ordinal adjusted from %s to %s",
+ i,
+ change.ordinal,
+ adjustedOrdinal,
+ )
+ TemplateChange(adjustedOrdinal, ChangeType.ADD)
+ }
+ ChangeType.DELETE -> change
+ }
+ adjustedChanges.add(adjustedChange)
+ }
+ return adjustedChanges
+ }
+
+ /**
+ * Scan the sequence of template add/deletes, looking for the given ordinal.
+ * When found, purge that ordinal and shift future changes down if they had ordinals higher than the one purged
+ */
+ private fun compactTemplateChanges(addedOrdinalToDelete: Int) {
+ Timber.d(
+ "compactTemplateChanges() merge/purge add/delete ordinal added as %s",
+ addedOrdinalToDelete,
+ )
+ var postChange = false
+ var ordinalAdjustment = 0
+ var i = 0
+ while (i < templateChanges.size) {
+ val change = templateChanges[i]
+ var ordinal = change.ordinal
+ val changeType = change.type
+ Timber.d("compactTemplateChanges() examining change entry %s / %s", ordinal, changeType)
+
+ // Only make adjustments after the ordinal we want to delete was added
+ if (!postChange) {
+ if (ordinal == addedOrdinalToDelete && changeType == ChangeType.ADD) {
+ Timber.d("compactTemplateChanges() found our entry at index %s", i)
+ // Remove this entry to start compaction, then fix up the loop counter since we altered size
+ postChange = true
+ templateChanges.removeAt(i)
+ i--
+ }
+ i++
+ continue
+ }
+
+ // We compact all deletes with higher ordinals, so any delete is below us: shift our comparison basis
+ if (changeType == ChangeType.DELETE) {
+ ordinalAdjustment++
+ Timber.d(
+ "compactTemplateChanges() delete affecting purged template, shifting basis, adj: %s",
+ ordinalAdjustment,
+ )
+ }
+
+ // If following ordinals were higher, we move them as part of compaction
+ if (ordinal + ordinalAdjustment > addedOrdinalToDelete) {
+ Timber.d("compactTemplateChanges() shifting later/higher ordinal down")
+ change.ordinal = --ordinal
+ }
+ i++
+ }
+ }
+
+ companion object {
+ /**
+ * Check if the given ordinal from the current UI state (which includes all pending changes) is a pending add
+ *
+ * @param ord int representing an ordinal in the note type, that might be an unsaved addition
+ * @return boolean true if it is a pending addition from this editing session
+ */
+ fun isOrdinalPendingAdd(
+ noteType: CardTemplateChanges,
+ ord: Int,
+ ): Boolean {
+ for (i in noteType.templateChanges.indices) {
+ // commented out to make the code compile, why is this unused?
+ // val change = noteType.templateChanges[i]
+ val adjustedOrdinal = getAdjustedAddOrdinalAtChangeIndex(noteType, i)
+ if (adjustedOrdinal == ord) {
+ Timber.d(
+ "isOrdinalPendingAdd() found ord %s was pending add (would adjust to %s)",
+ ord,
+ adjustedOrdinal,
+ )
+ return true
+ }
+ }
+ Timber.d("isOrdinalPendingAdd() ord %s is not a pending add", ord)
+ return false
+ }
+
+ /**
+ * Check if the change at the given index in the changes array is an addition from this editing session
+ * (and thus is not in the database yet, and possibly needing ordinal adjustment from subsequent deletes)
+ * @param changesIndex the index of the template in the changes array
+ * @return either ordinal adjusted by any pending deletes if it is a pending add, or -1 if the ordinal is not an add
+ */
+ fun getAdjustedAddOrdinalAtChangeIndex(
+ noteType: CardTemplateChanges,
+ changesIndex: Int,
+ ): Int {
+ if (changesIndex >= noteType.templateChanges.size) {
+ return -1
+ }
+ var ordinalAdjustment = 0
+ val change = noteType.templateChanges[changesIndex]
+ val ordinalToInspect = change.ordinal
+ for (i in noteType.templateChanges.size - 1 downTo changesIndex) {
+ val oldChange = noteType.templateChanges[i]
+ val currentOrdinal = change.ordinal
+ when (oldChange.type) {
+ ChangeType.DELETE -> {
+ // Deleting an ordinal at or below us? Adjust our comparison basis...
+ if (currentOrdinal - ordinalAdjustment <= ordinalToInspect) {
+ ordinalAdjustment++
+ continue
+ }
+ Timber.d(
+ "getAdjustedAddOrdinalAtChangeIndex() contemplating delete at index %s, current ord adj %s",
+ i,
+ ordinalAdjustment,
+ )
+ }
+ ChangeType.ADD ->
+ if (changesIndex == i) {
+ // something we added this session
+ Timber.d(
+ "getAdjustedAddOrdinalAtChangeIndex() pending add found at at index %s, old ord/adjusted ord %s/%s",
+ i,
+ currentOrdinal,
+ currentOrdinal - ordinalAdjustment,
+ )
+ return currentOrdinal - ordinalAdjustment
+ }
+ }
+ }
+ Timber.d(
+ "getAdjustedAddOrdinalAtChangeIndex() determined changesIndex %s was not a pending add",
+ changesIndex,
+ )
+ return -1
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt
index a9385bc6370b..eebc07c52163 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt
@@ -237,11 +237,11 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi
ResizablePaneManager(
parentLayout = requireNotNull(binding.cardTemplateEditorXlView) { "cardTemplateEditorXlView" },
divider = requireNotNull(binding.cardTemplateEditorResizingDivider) { "cardTemplateEditorResizingDivider" },
- leftPane = requireNotNull(binding.templateEditor.root) { "templateEditor.root" },
- rightPane = requireNotNull(binding.fragmentContainer) { "fragmentContainer" },
+ startPane = requireNotNull(binding.templateEditor.root) { "templateEditor.root" },
+ endPane = requireNotNull(binding.fragmentContainer) { "fragmentContainer" },
sharedPrefs = Prefs.getUiConfig(this),
- leftPaneWeightKey = PREF_TEMPLATE_EDITOR_PANE_WEIGHT,
- rightPaneWeightKey = PREF_TEMPLATE_PREVIEWER_PANE_WEIGHT,
+ startPaneWeightKey = PREF_TEMPLATE_EDITOR_PANE_WEIGHT,
+ endPaneWeightKey = PREF_TEMPLATE_PREVIEWER_PANE_WEIGHT,
)
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateNotetype.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateNotetype.kt
index 9245c5c8d18e..650b701a4dda 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateNotetype.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateNotetype.kt
@@ -35,8 +35,13 @@ class CardTemplateNotetype(
val type: ChangeType,
) : java.io.Serializable
- var templateChanges = ArrayList()
- private set
+ private val changes = CardTemplateChanges()
+
+ var templateChanges: ArrayList
+ get() = changes.templateChanges
+ private set(value) {
+ changes.templateChanges = value
+ }
fun toBundle(): Bundle =
Bundle().apply {
@@ -88,7 +93,7 @@ class CardTemplateNotetype(
suspend fun saveToDatabase() {
Timber.d("saveToDatabase() called")
- dumpChanges()
+ changes.dumpChanges()
clearTempNoteTypeFiles()
saveNoteType(notetype, adjustedTemplateChanges)
}
@@ -126,198 +131,15 @@ class CardTemplateNotetype(
}
}
- /**
- * Template deletes shift card ordinals in the database. To operate without saving, we must keep track to apply in order.
- * In addition, we don't want to persist a template add just to delete it later, so we combine those if they happen
- */
fun addTemplateChange(
type: ChangeType,
ordinal: Int,
- ) {
- Timber.d("addTemplateChange() type %s for ordinal %s", type, ordinal)
- val templateChanges = templateChanges
- val change = TemplateChange(ordinal, type)
-
- // If we are deleting something we added but have not saved, edit it out of the change list
- if (type == ChangeType.DELETE) {
- var ordinalAdjustment = 0
- for (i in templateChanges.indices.reversed()) {
- val oldChange = templateChanges[i]
- when (oldChange.type) {
- ChangeType.DELETE ->
- if (oldChange.ordinal - ordinalAdjustment <= ordinal) {
- // Deleting an ordinal at or below us? Adjust our comparison basis...
- ordinalAdjustment++
- continue
- }
- ChangeType.ADD ->
- if (ordinal == oldChange.ordinal - ordinalAdjustment) {
- // Deleting something we added this session? Edit it out via compaction
- compactTemplateChanges(oldChange.ordinal)
- return
- }
- }
- }
- }
- Timber.d("addTemplateChange() added ord/type: %s/%s", change.ordinal, change.type)
- templateChanges.add(change)
- dumpChanges()
- }
-
- /**
- * Return an int[] containing the collection-relative ordinals of all the currently pending deletes,
- * including the ordinal passed in, as opposed to the changelist-relative ordinals
- *
- * @param ord int UI-relative ordinal to check database for delete safety along with existing deletes
- * @return int[] of all ordinals currently in the database, pending delete
- */
- fun getDeleteDbOrds(ord: Int): IntArray {
- dumpChanges()
- Timber.d("getDeleteDbOrds()")
-
- // array containing the original / db-relative ordinals for all pending deletes plus the proposed one
- val deletedDbOrds = ArrayList(templateChanges.size)
-
- // For each entry in the changes list - and the proposed delete - scan for deletes to get original ordinal
- for (i in 0..templateChanges.size) {
- var ordinalAdjustment = 0
-
- // We need an initializer. Though proposed change is checked last, it's a reasonable default initializer.
- var currentChange = TemplateChange(ord, ChangeType.DELETE)
- if (i < templateChanges.size) {
- // Until we exhaust the pending change list we will use them
- currentChange = templateChanges[i]
- }
-
- // If the current pending change isn't a delete, it is unimportant here
- if (currentChange.type !== ChangeType.DELETE) {
- continue
- }
-
- // If it is a delete, scan previous deletes and shift as necessary for original ord
- for (j in 0 until i) {
- val previousChange = templateChanges[j]
-
- // Is previous change a delete? Lower ordinal than current change?
- if (previousChange.type === ChangeType.DELETE && previousChange.ordinal <= currentChange.ordinal) {
- // If so, that is the case where things shift. It means our ordinals moved and original ord is higher
- ordinalAdjustment++
- }
- }
-
- // We know how many times ordinals smaller than the current were deleted so we have the total adjustment
- // Save this pending delete at it's original / db-relative position
- deletedDbOrds.add(currentChange.ordinal + ordinalAdjustment)
- }
- val deletedDbOrdInts = IntArray(deletedDbOrds.size)
- for (i in deletedDbOrdInts.indices) {
- deletedDbOrdInts[i] = deletedDbOrds[i]
- }
- return deletedDbOrdInts
- }
+ ) = changes.addTemplateChange(type, ordinal)
- private fun dumpChanges() {
- if (!BuildConfig.DEBUG) {
- return
- }
- val adjustedChanges = adjustedTemplateChanges
- for (i in templateChanges.indices) {
- val change = templateChanges[i]
- val adjustedChange = adjustedChanges[i]
- Timber.d("dumpChanges() Change %s is ord/type %s/%s", i, change.ordinal, change.type)
- Timber.d(
- "dumpChanges() During save change %s will be ord/type %s/%s",
- i,
- adjustedChange.ordinal,
- adjustedChange.type,
- )
- }
- }
+ fun getDeleteDbOrds(ord: Int): IntArray = changes.getDeleteDbOrds(ord)
- /**
- * Adjust the ordinals in our accrued change list so that any pending adds have the correct
- * ordinal after taking into account any pending deletes
- *
- * @return ArrayList[2]> of [ordinal][ChangeType] entries
- */
val adjustedTemplateChanges: ArrayList
- get() {
- val changes = templateChanges
- val adjustedChanges = ArrayList(changes.size)
-
- // In order to save the changes into the database, the ordinals in the changelist must correspond to the
- // ordinals in the database (for deletes) or the correct index in the changes array (for adds)
- // It is not possible to know what those will be until the user requests a save, so they are stored in the
- // change list as-is until the save time comes, then the adjustment is made all at once
- for (i in changes.indices) {
- val change = changes[i]
- val adjustedChange =
- when (change.type) {
- ChangeType.ADD -> {
- val adjustedOrdinal = getAdjustedAddOrdinalAtChangeIndex(this, i)
- Timber.d(
- "getAdjustedTemplateChanges() change %s ordinal adjusted from %s to %s",
- i,
- change.ordinal,
- adjustedOrdinal,
- )
- TemplateChange(adjustedOrdinal, ChangeType.ADD)
- }
- ChangeType.DELETE -> change
- }
- adjustedChanges.add(adjustedChange)
- }
- return adjustedChanges
- }
-
- /**
- * Scan the sequence of template add/deletes, looking for the given ordinal.
- * When found, purge that ordinal and shift future changes down if they had ordinals higher than the one purged
- */
- private fun compactTemplateChanges(addedOrdinalToDelete: Int) {
- Timber.d(
- "compactTemplateChanges() merge/purge add/delete ordinal added as %s",
- addedOrdinalToDelete,
- )
- var postChange = false
- var ordinalAdjustment = 0
- var i = 0
- while (i < templateChanges.size) {
- val change = templateChanges[i]
- var ordinal = change.ordinal
- val changeType = change.type
- Timber.d("compactTemplateChanges() examining change entry %s / %s", ordinal, changeType)
-
- // Only make adjustments after the ordinal we want to delete was added
- if (!postChange) {
- if (ordinal == addedOrdinalToDelete && changeType == ChangeType.ADD) {
- Timber.d("compactTemplateChanges() found our entry at index %s", i)
- // Remove this entry to start compaction, then fix up the loop counter since we altered size
- postChange = true
- templateChanges.removeAt(i)
- i--
- }
- i++
- continue
- }
-
- // We compact all deletes with higher ordinals, so any delete is below us: shift our comparison basis
- if (changeType == ChangeType.DELETE) {
- ordinalAdjustment++
- Timber.d(
- "compactTemplateChanges() delete affecting purged template, shifting basis, adj: %s",
- ordinalAdjustment,
- )
- }
-
- // If following ordinals were higher, we move them as part of compaction
- if (ordinal + ordinalAdjustment > addedOrdinalToDelete) {
- Timber.d("compactTemplateChanges() shifting later/higher ordinal down")
- change.ordinal = --ordinal
- }
- i++
- }
- }
+ get() = changes.adjustedTemplateChanges
companion object {
const val INTENT_MODEL_FILENAME = "editedNoteTypeFilename"
@@ -400,84 +222,15 @@ class CardTemplateNotetype(
return deleteCount
}
- /**
- * Check if the given ordinal from the current UI state (which includes all pending changes) is a pending add
- *
- * @param ord int representing an ordinal in the note type, that might be an unsaved addition
- * @return boolean true if it is a pending addition from this editing session
- */
fun isOrdinalPendingAdd(
noteType: CardTemplateNotetype,
ord: Int,
- ): Boolean {
- for (i in noteType.templateChanges.indices) {
- // commented out to make the code compile, why is this unused?
- // val change = noteType.templateChanges[i]
- val adjustedOrdinal = getAdjustedAddOrdinalAtChangeIndex(noteType, i)
- if (adjustedOrdinal == ord) {
- Timber.d(
- "isOrdinalPendingAdd() found ord %s was pending add (would adjust to %s)",
- ord,
- adjustedOrdinal,
- )
- return true
- }
- }
- Timber.d("isOrdinalPendingAdd() ord %s is not a pending add", ord)
- return false
- }
+ ): Boolean = CardTemplateChanges.isOrdinalPendingAdd(noteType.changes, ord)
- /**
- * Check if the change at the given index in the changes array is an addition from this editing session
- * (and thus is not in the database yet, and possibly needing ordinal adjustment from subsequent deletes)
- * @param changesIndex the index of the template in the changes array
- * @return either ordinal adjusted by any pending deletes if it is a pending add, or -1 if the ordinal is not an add
- */
fun getAdjustedAddOrdinalAtChangeIndex(
noteType: CardTemplateNotetype,
changesIndex: Int,
- ): Int {
- if (changesIndex >= noteType.templateChanges.size) {
- return -1
- }
- var ordinalAdjustment = 0
- val change = noteType.templateChanges[changesIndex]
- val ordinalToInspect = change.ordinal
- for (i in noteType.templateChanges.size - 1 downTo changesIndex) {
- val oldChange = noteType.templateChanges[i]
- val currentOrdinal = change.ordinal
- when (oldChange.type) {
- ChangeType.DELETE -> {
- // Deleting an ordinal at or below us? Adjust our comparison basis...
- if (currentOrdinal - ordinalAdjustment <= ordinalToInspect) {
- ordinalAdjustment++
- continue
- }
- Timber.d(
- "getAdjustedAddOrdinalAtChangeIndex() contemplating delete at index %s, current ord adj %s",
- i,
- ordinalAdjustment,
- )
- }
- ChangeType.ADD ->
- if (changesIndex == i) {
- // something we added this session
- Timber.d(
- "getAdjustedAddOrdinalAtChangeIndex() pending add found at at index %s, old ord/adjusted ord %s/%s",
- i,
- currentOrdinal,
- currentOrdinal - ordinalAdjustment,
- )
- return currentOrdinal - ordinalAdjustment
- }
- }
- }
- Timber.d(
- "getAdjustedAddOrdinalAtChangeIndex() determined changesIndex %s was not a pending add",
- changesIndex,
- )
- return -1
- }
+ ): Int = CardTemplateChanges.getAdjustedAddOrdinalAtChangeIndex(noteType.changes, changesIndex)
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardUtils.kt
index d0ddaa450cda..6d443db76b9e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CardUtils.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardUtils.kt
@@ -1,4 +1,5 @@
-//noinspection MissingCopyrightHeader #8659
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki
import com.ichi2.anki.libanki.Card
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt
index 058b76403b1d..be57b28f4648 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt
@@ -11,7 +11,6 @@ import android.content.DialogInterface
import android.database.sqlite.SQLiteDatabaseCorruptException
import android.net.Uri
import android.text.format.Formatter
-import android.view.WindowManager
import android.view.WindowManager.BadTokenException
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
@@ -31,11 +30,17 @@ 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.common.utils.android.isRobolectric
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.progress.blockTouches
+import com.ichi2.anki.progress.unblockTouches
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.startup.redirectToMainEntryPoint
import com.ichi2.anki.ui.internationalization.sentenceCase
@@ -72,6 +77,7 @@ import net.ankiweb.rsdroid.exceptions.BackendSyncException
import org.jetbrains.annotations.VisibleForTesting
import timber.log.Timber
import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/** Overridable reference to [Dispatchers.IO]. Useful if tests can't use it */
@@ -163,7 +169,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))
@@ -222,14 +230,49 @@ fun FragmentActivity.asyncCatching(
runCatching(errorMessage, skipCrashReport = skipCrashReport) { block() }
}
-/** See [FragmentActivity.launchCatchingTask] */
+/**
+ * Launch a job that catches any uncaught errors and reports them to the user.
+ * Errors from the backend contain localized text that is often suitable to show to the user as-is.
+ * Other errors should ideally be handled in the block.
+ *
+ * A valid, non-finishing activity must be used when running this.
+ */
fun Fragment.launchCatchingTask(
errorMessage: String? = null,
skipCrashReport: ((Exception) -> Boolean)? = null,
block: suspend CoroutineScope.() -> Unit,
): Job =
lifecycle.coroutineScope.launch {
- requireActivity().runCatching(errorMessage, skipCrashReport = skipCrashReport) { block() }
+ // It's a bug to call this if the activity is invalid or finishing.
+ val validActivity =
+ kotlin
+ .runCatching {
+ requireActivity(simpleName = true).also { activity ->
+ check(!activity.isFinishing) {
+ "Cannot launch task for ${this@launchCatchingTask.javaClass.simpleName}: activity is finishing"
+ }
+ }
+ }.getOrElse {
+ Timber.w(it, "Fragment.launchCatchingTask")
+ if (isRobolectric) throw it
+ return@launch
+ }
+ validActivity.runCatching(errorMessage, skipCrashReport = skipCrashReport) { block() }
+ }
+
+/**
+ * [Fragment.requireActivity], with a short stable name so crash reports can be grouped together.
+ *
+ * ```diff
+ * - Fragment {afc1c3c} (f8db5cf7-13f5-495f-8195-79e0be3aff68) not attached to an activity.
+ * + Fragment not attached to an activity.
+ * ```
+ */
+fun Fragment.requireActivity(simpleName: Boolean): FragmentActivity =
+ if (simpleName) {
+ checkNotNull(activity) { "Fragment ${javaClass.simpleName} not attached to an activity." }
+ } else {
+ requireActivity()
}
/**
@@ -391,7 +434,7 @@ suspend fun Fragment.withProgress(
suspend fun withProgressDialog(
context: Activity,
onCancel: (() -> Unit)?,
- delayMillis: Long = 600,
+ delayDuration: Duration = 600.milliseconds,
@StringRes manualCancelButton: Int? = null,
op: suspend (android.app.ProgressDialog) -> T,
): T =
@@ -416,17 +459,15 @@ suspend fun withProgressDialog(
}
}
// disable taps immediately
- context.runOnUiThread {
- context.window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
- }
+ context.runOnUiThread { context.window.blockTouches() }
// reveal the dialog after 600ms
var dialogIsOurs = false
val dialogJob =
launch {
- delay(delayMillis)
+ delay(delayDuration)
if (!AnkiDroidApp.instance.progressDialogShown) {
Timber.i(
- """Displaying progress dialog: ${delayMillis}ms elapsed;
+ """Displaying progress dialog: $delayDuration elapsed;
|cancellable: ${onCancel != null};
|manualCancel: ${manualCancelButton != null}
|
@@ -450,7 +491,7 @@ suspend fun withProgressDialog(
} finally {
dialogJob.cancel()
dismissDialogIfShowing(dialog)
- context.runOnUiThread { context.window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) }
+ context.runOnUiThread { context.window.unblockTouches() }
if (dialogIsOurs) {
AnkiDroidApp.instance.progressDialogShown = false
}
@@ -489,7 +530,7 @@ private suspend fun ProgressContext.monitorProgress(
withContext(Dispatchers.Main) {
state.updateUi()
}
- delay(100)
+ delay(100.milliseconds)
}
}
@@ -686,6 +727,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 +769,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/CustomActionModeCallback.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CustomActionModeCallback.kt
index 6c1199abb57a..c513d19cfafc 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CustomActionModeCallback.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CustomActionModeCallback.kt
@@ -1,19 +1,5 @@
-/*
- * Copyright (c) 2024 Ashish Yadav
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2024 Ashish Yadav
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DatabaseCheck.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DatabaseCheck.kt
index 3f6060f2b0ca..fefacfadd9bc 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DatabaseCheck.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DatabaseCheck.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2022 Ankitects Pty Ltd
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2022 Ankitects Pty Ltd
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
index 120d4c1ce794..7f8194ae4104 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
@@ -67,6 +67,7 @@ import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+import androidx.work.NetworkType
import androidx.work.WorkInfo
import androidx.work.WorkManager
import anki.collection.OpChanges
@@ -118,6 +119,7 @@ import com.ichi2.anki.databinding.IncludeDeckPickerBinding
import com.ichi2.anki.databinding.IncludeFloatingAddButtonBinding
import com.ichi2.anki.deckpicker.BackgroundImage
import com.ichi2.anki.deckpicker.DeckDeletionResult
+import com.ichi2.anki.deckpicker.DeckPickerProgress
import com.ichi2.anki.deckpicker.DeckPickerViewModel
import com.ichi2.anki.deckpicker.DeckPickerViewModel.AnkiDroidEnvironment
import com.ichi2.anki.deckpicker.DeckPickerViewModel.FlattenedDeckList
@@ -136,6 +138,8 @@ import com.ichi2.anki.dialogs.DeckPickerBackupNoSpaceLeftDialog
import com.ichi2.anki.dialogs.DeckPickerConfirmDeleteDeckDialog
import com.ichi2.anki.dialogs.DeckPickerContextMenu
import com.ichi2.anki.dialogs.DeckPickerContextMenu.DeckPickerContextMenuOption
+import com.ichi2.anki.dialogs.DeckPickerMaintenanceDialog
+import com.ichi2.anki.dialogs.DeckPickerMaintenanceDialog.Check
import com.ichi2.anki.dialogs.DeckPickerNoSpaceLeftDialog
import com.ichi2.anki.dialogs.DialogHandlerMessage
import com.ichi2.anki.dialogs.EditDeckDescriptionDialog
@@ -163,6 +167,7 @@ import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.pages.AnkiPackageImporterFragment
import com.ichi2.anki.pages.CongratsPage
import com.ichi2.anki.pages.CongratsPage.Companion.onDeckCompleted
+import com.ichi2.anki.progress.observeProgress
import com.ichi2.anki.receiver.SdCardReceiver
import com.ichi2.anki.reviewreminders.ReviewReminderScope
import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase
@@ -188,6 +193,7 @@ import com.ichi2.anki.utils.ShortcutUtils
import com.ichi2.anki.utils.ext.dismissAllDialogFragments
import com.ichi2.anki.utils.ext.doOnScrolled
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
+import com.ichi2.anki.utils.ext.onGlobalLayout
import com.ichi2.anki.utils.ext.positionIsVisible
import com.ichi2.anki.utils.ext.setFragmentResultListener
import com.ichi2.anki.utils.ext.setImageDrawableSafe
@@ -209,7 +215,6 @@ import com.ichi2.utils.configureView
import com.ichi2.utils.customView
import com.ichi2.utils.dp
import com.ichi2.utils.message
-import com.ichi2.utils.negativeButton
import com.ichi2.utils.positiveButton
import com.ichi2.utils.show
import com.ichi2.utils.showDialogIfWebViewOutdated
@@ -221,6 +226,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.ankiweb.rsdroid.Translations
+import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException.BackendCollectionNotOpenException
import timber.log.Timber
import java.io.File
import kotlin.time.Duration
@@ -625,6 +631,14 @@ open class DeckPicker :
handleContextMenuSelection(result.option, result.deckId)
}
+ setFragmentResultListener(Check.MEDIA.requestKey) { _, _ ->
+ Timber.i("Starting media check")
+ startActivity(MediaCheckFragment.getIntent(this))
+ }
+ setFragmentResultListener(Check.DATABASE.requestKey) { _, _ ->
+ performIntegrityCheck()
+ }
+
registerStudyOptionsStudyHandler {
Timber.d("Opening study screen from DeckPicker's study options panel")
openReviewer()
@@ -646,6 +660,7 @@ open class DeckPicker :
onReceiveContentListener,
)
+ observeProgress(viewModel) { progress -> progressMessage(progress) }
setupFlows()
}
@@ -726,7 +741,7 @@ open class DeckPicker :
}
// Insets move the FAB's ancestors without changing the FAB's bounds within its parent.
// Wait until the whole hierarchy is laid out before reading positions in the window.
- deckPickerBinding.root.viewTreeObserver.addOnGlobalLayoutListener {
+ deckPickerBinding.root.onGlobalLayout(this) {
setRecyclerViewBottomPaddingAbove(listAnchor())
}
deckPickerBinding.reviewSummaryTextView.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
@@ -902,7 +917,8 @@ open class DeckPicker :
fun onDecksReloaded(param: Unit) {
hideProgressBar()
- tryShowStudyOptionsPanel()
+ // TODO: Refresh deck metadata in the reminder pane
+ fragment?.refreshInterface()
}
fun onStartupResponse(response: StartupResponse) {
@@ -919,21 +935,29 @@ open class DeckPicker :
// Set flowOfStartupResponse to null after handling so it isn't re-emitted on resume.
// Must stay here: clearing in ViewModel would break cold start (collector is only active at RESUMED).
viewModel.flowOfStartupResponse.value = null
+ invalidateOptionsMenu()
showStartupScreensAndDialogs(sharedPrefs(), 0)
if (tryShowStudyOptionsPanel()) {
ResizablePaneManager(
parentLayout = requireNotNull(binding.deckpickerXlView) { "deckpickerXlView" },
divider = requireNotNull(binding.resizingDivider) { "resizingDivider" },
- leftPane = deckPickerBinding.root,
- rightPane = requireNotNull(binding.studyoptionsFragment) { "studyoptionsFragment" },
+ startPane = deckPickerBinding.root,
+ endPane = requireNotNull(binding.studyoptionsFragment) { "studyoptionsFragment" },
sharedPrefs = Prefs.getUiConfig(this),
- leftPaneWeightKey = PREF_DECK_PICKER_PANE_WEIGHT,
- rightPaneWeightKey = PREF_STUDY_OPTIONS_PANE_WEIGHT,
+ startPaneWeightKey = PREF_DECK_PICKER_PANE_WEIGHT,
+ endPaneWeightKey = PREF_STUDY_OPTIONS_PANE_WEIGHT,
+ onResizingChanged = { isResizing -> fragment?.onResizingChanged(isResizing) },
)
}
}
- is StartupResponse.FatalError -> handleStartupFailure(response.failure)
+ is StartupResponse.FatalError -> {
+ // the startup check is asynchronous, so the menu may already have been
+ // built by the time a failure arrives; rebuild it so onCreateOptionsMenu
+ // sees the error and blanks it
+ invalidateOptionsMenu()
+ handleStartupFailure(response.failure)
+ }
}
}
@@ -947,6 +971,12 @@ open class DeckPicker :
viewModel.deckDeletedNotification.launchCollectionInLifecycleScope(::onDeckDeleted)
viewModel.flowOfDeleteDeckConfirmation.launchCollectionInLifecycleScope(::showDeleteDeckConfirmationDialog)
+ viewModel.flowOfRenameDeck.launchCollectionInLifecycleScope(::renameDeckDialog)
+ viewModel.flowOfStudyDeck.launchCollectionInLifecycleScope { deckId ->
+ launchCatchingTask {
+ handleDeckSelection(deckId, DeckSelectionType.SKIP_STUDY_OPTIONS)
+ }
+ }
viewModel.emptyCardsNotification.launchCollectionInLifecycleScope(::onCardsEmptied)
viewModel.flowOfDeckCountsChanged.launchCollectionInLifecycleScope(::onDeckCountsChanged)
viewModel.flowOfDestination.launchCollectionInLifecycleScope(::onDestinationChanged)
@@ -1095,6 +1125,8 @@ open class DeckPicker :
override val preferences: SharedPreferences
get() = context.sharedPrefs()
+ override fun decideStorageIfUndecided() = InitialActivity.decideStorageIfUndecided(context)
+
override fun initializeAnkiDroidFolder(): Boolean = CollectionHelper.isCurrentAnkiDroidDirAccessible(context)
}
@@ -1222,6 +1254,7 @@ open class DeckPicker :
// redraw menu synchronously to avoid flicker
updateMenuFromState(menu)
updateSearchVisibilityFromState(menu)
+ restoreSearchState(menu)
// ...then launch a task to possibly update the visible icons.
// Store the job so that tests can easily await it. In the future
// this may be better done by injecting a custom test scheduler
@@ -1230,6 +1263,7 @@ open class DeckPicker :
launchCatchingTask {
viewModel.refreshMenuState()
updateSearchVisibilityFromState(menu)
+ restoreSearchState(menu)
updateDeckRelatedMenuItems(menu)
updateMenuFromState(menu)
}
@@ -1269,6 +1303,7 @@ open class DeckPicker :
// When SearchItem is expanded
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
Timber.i("DeckPicker:: SearchItem opened")
+ viewModel.updateDeckSearchExpanded(true)
// Hide the floating action button if it is visible
floatingActionMenu.hideFloatingActionButton()
activeSnackBar?.anchorView = null
@@ -1278,6 +1313,8 @@ open class DeckPicker :
// When SearchItem is collapsed
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
Timber.i("DeckPicker:: SearchItem closed")
+ viewModel.updateDeckSearchExpanded(false)
+ viewModel.updateDeckFilter("")
// Show the floating action button if it is hidden
floatingActionMenu.showFloatingActionButton()
activeSnackBar?.anchorView = floatingActionButtonBinding.fabMain
@@ -1286,7 +1323,8 @@ open class DeckPicker :
},
)
- (menuItem.actionView as AccessibleSearchView).run {
+ val searchView = menuItem.actionView as AccessibleSearchView
+ searchView.run {
queryHint = getString(CommonString.search_decks)
setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
@@ -1296,7 +1334,21 @@ open class DeckPicker :
}
override fun onQueryTextChange(newText: String): Boolean {
- viewModel.updateDeckFilter(newText)
+ if (newText.isNotEmpty()) {
+ viewModel.updateDeckFilter(newText)
+ } else {
+ // Ignore empty callbacks from a replaced action view.
+ searchView.post {
+ if (
+ !isDestroyed &&
+ toolbarSearchView === searchView &&
+ menuItem.isActionViewExpanded &&
+ searchView.query.isEmpty()
+ ) {
+ viewModel.updateDeckFilter("")
+ }
+ }
+ }
return true
}
},
@@ -1305,23 +1357,35 @@ open class DeckPicker :
searchDecksIcon = menuItem
}
+ private fun restoreSearchState(menu: Menu) {
+ val searchItem = menu.findItem(R.id.deck_picker_action_filter)
+ if (viewModel.isDeckSearchExpanded && searchItem.isVisible) {
+ val searchView = searchItem.actionView as AccessibleSearchView
+ val query = viewModel.deckSearchQuery
+ // Expanding the action view clears its text, so restore the query afterwards.
+ searchItem.expandActionView()
+ searchView.setQuery(query, false)
+ }
+ }
+
fun updateMenuFromState(menu: Menu) {
viewModel.optionsMenuState?.run {
- updateUndoLabelFromState(menu.findItem(R.id.action_undo), undoLabel, undoAvailable)
- updateSyncIconFromState(menu.findItem(R.id.action_sync), this)
+ // The menu may have been cleared while refreshMenuState was suspended.
+ menu.findItem(R.id.action_undo)?.let { updateUndoLabelFromState(it, undoLabel, undoAvailable) }
+ menu.findItem(R.id.action_sync)?.let { updateSyncIconFromState(it, this) }
}
updateDeckRelatedMenuItems(menu)
}
/**
- * Shows/hides deck related menu items based on the collection being empty or not.
+ * Shows/hides deck related menu items using the ViewModel's action eligibility.
*/
private fun updateDeckRelatedMenuItems(menu: Menu) {
viewModel.optionsMenuState?.run {
- menu.findItem(R.id.action_deck_rename)?.isVisible = !isColEmpty
- menu.findItem(R.id.action_deck_delete)?.isVisible = !isColEmpty
+ menu.findItem(R.id.action_deck_rename)?.isVisible = showDeckActions
+ menu.findItem(R.id.action_deck_delete)?.isVisible = showDeckActions
// added to the menu by StudyOptionsFragment
- menu.findItem(R.id.action_deck_or_study_options)?.isVisible = !isColEmpty
+ menu.findItem(R.id.action_deck_or_study_options)?.isVisible = showDeckActions
}
}
@@ -1439,17 +1503,12 @@ open class DeckPicker :
return true
}
R.id.action_deck_rename -> {
- launchCatchingTask {
- val targetDeckId = withCol { decks.selected() }
- renameDeckDialog(targetDeckId)
- }
+ viewModel.requestRenameSelectedDeck()
return true
}
R.id.action_deck_delete -> {
launchCatchingTask {
- withProgress(resources.getString(CommonString.delete_deck)) {
- viewModel.deleteSelectedDeck().join()
- }
+ viewModel.deleteSelectedDeck().join()
}
return true
}
@@ -1470,25 +1529,14 @@ open class DeckPicker :
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun createBackup(): Job =
launchCatchingTask {
- val created =
- withProgress(message = TR.sentenceCase.creatingBackup) {
- performBackupInBackground(true)
- }
+ val created = viewModel.createBackup()
val message = if (created) TR.profilesBackupCreated() else TR.profilesBackupUnchanged()
showThemedToast(this@DeckPicker, message, false)
}
private fun showMediaCheckDialog() {
Timber.i("showing media check dialog")
- AlertDialog.Builder(this).show {
- title(text = TR.sentenceCase.checkMediaTitle)
- message(text = getString(CommonString.check_media_warning))
- positiveButton(CommonString.dialog_ok) {
- Timber.i("Starting media check")
- startActivity(MediaCheckFragment.getIntent(this@DeckPicker))
- }
- negativeButton(CommonString.dialog_cancel)
- }
+ DeckPickerMaintenanceDialog.newInstance(Check.MEDIA).show(supportFragmentManager, DeckPickerMaintenanceDialog.TAG)
}
fun showCreateFilteredDeckDialog() {
@@ -1516,6 +1564,7 @@ open class DeckPicker :
if (navDrawerIsReady() && hasCollectionStoragePermissions()) {
refreshState()
}
+ viewModel.onScreenResumed()
message?.let { dialogHandler.sendStoredMessage(it) }
}
@@ -1545,6 +1594,7 @@ open class DeckPicker :
}
}
outState.putSerializable("mediaUsnOnConflict", mediaUsnOnConflict)
+ outState.putSerializable("mediaNetworkTypeOnConflict", mediaNetworkTypeOnConflict)
floatingActionMenu.showFloatingActionButton()
}
@@ -1556,6 +1606,8 @@ open class DeckPicker :
importColpkgListener = DatabaseRestorationListener(this, path)
}
mediaUsnOnConflict = savedInstanceState.getSerializableCompat("mediaUsnOnConflict")
+ mediaNetworkTypeOnConflict =
+ savedInstanceState.getSerializableCompat("mediaNetworkTypeOnConflict") ?: NetworkType.UNMETERED
showRestoredBottomNavTab()
}
@@ -1574,18 +1626,23 @@ open class DeckPicker :
*/
private suspend fun automaticSync(runInBackground: Boolean = false): Boolean {
/**
- * @return whether there are collection changes to be sync.
+ * @return whether there are collection changes to sync, or null if the collection is closed.
*
* It DOES NOT include if there are media to be synced.
*/
- suspend fun areThereChangesToSync(): Boolean {
+ suspend fun areThereChangesToSync(): Boolean? {
val auth = syncAuth() ?: return false
val status =
withContext(Dispatchers.IO) {
- CollectionManager.getBackend().syncStatus(auth)
- }.required
+ // The status check may wait for the network. Do not hold the collection queue.
+ try {
+ CollectionManager.getBackend().syncStatus(auth)
+ } catch (_: BackendCollectionNotOpenException) {
+ null
+ }
+ } ?: return null
- return when (status) {
+ return when (status.required) {
SyncStatusResponse.Required.NO_CHANGES,
SyncStatusResponse.Required.UNRECOGNIZED,
null,
@@ -1605,24 +1662,31 @@ open class DeckPicker :
!NetworkUtils.isOnline -> Timber.d("autoSync: offline")
!runInBackground && !syncIntervalPassed() -> Timber.d("autoSync: interval not passed")
!isLoggedIn() -> Timber.d("autoSync: not logged in")
- !areThereChangesToSync() -> {
- Timber.d("autoSync: no collection changes to sync. Syncing media if set")
- if (shouldFetchMedia()) {
- val auth = syncAuth() ?: return false
- SyncMediaWorker.start(this, auth)
- }
- setLastSyncTimeToNow()
- }
else -> {
- if (runInBackground) {
- Timber.i("autoSync: starting background")
- val auth = syncAuth() ?: return false
- SyncWorker.start(this, auth, shouldFetchMedia())
+ // Startup may have failed to open the collection. Do not sync on exit in that case.
+ val hasChanges = areThereChangesToSync()
+ if (hasChanges == null) {
+ Timber.d("autoSync: collection not open")
+ return false
+ }
+ if (!hasChanges) {
+ Timber.d("autoSync: no collection changes to sync. Syncing media if set")
+ if (shouldFetchMedia()) {
+ val auth = syncAuth() ?: return false
+ SyncMediaWorker.start(this, auth)
+ }
+ setLastSyncTimeToNow()
} else {
- Timber.i("autoSync: starting foreground")
- sync()
+ if (runInBackground) {
+ Timber.i("autoSync: starting background")
+ val auth = syncAuth() ?: return false
+ SyncWorker.start(this, auth, shouldFetchMedia())
+ } else {
+ Timber.i("autoSync: starting foreground")
+ sync()
+ }
+ return true
}
- return true
}
}
return false
@@ -1690,9 +1754,7 @@ open class DeckPicker :
}
KeyEvent.KEYCODE_S -> {
Timber.i("Study from keypress")
- launchCatchingTask {
- handleDeckSelection(withCol { decks.selected() }, DeckSelectionType.SKIP_STUDY_OPTIONS)
- }
+ viewModel.requestStudySelectedDeck()
return true
}
KeyEvent.KEYCODE_T -> {
@@ -1959,14 +2021,9 @@ open class DeckPicker :
val status = CollectionIntegrityStorageCheck.createInstance(this)
if (status.shouldWarnOnIntegrityCheck()) {
Timber.d("Displaying File Size confirmation")
- AlertDialog.Builder(this).show {
- title(text = TR.sentenceCase.checkDatabase)
- message(text = status.getWarningDetails(this@DeckPicker))
- positiveButton(CommonString.integrity_check_continue_anyway) {
- performIntegrityCheck()
- }
- negativeButton(CommonString.dialog_cancel)
- }
+ DeckPickerMaintenanceDialog
+ .newInstance(Check.DATABASE, status.getWarningDetails(this))
+ .show(supportFragmentManager, DeckPickerMaintenanceDialog.TAG)
} else {
performIntegrityCheck()
}
@@ -2000,10 +2057,16 @@ open class DeckPicker :
return false
}
+ // TODO: Move both conflict fields into pending full-sync context carried by dialog arguments,
+ // confirmation callbacks, and deferred dialog messages.
+
/** In the conflict case, we need to store the USN received from the initial sync, and reuse
it after the user has decided. */
var mediaUsnOnConflict: Int? = null
+ /** Preserve the original attempt's network restriction while the user chooses upload/download. */
+ var mediaNetworkTypeOnConflict: NetworkType = NetworkType.UNMETERED
+
/**
* The mother of all syncing attempts. This might be called from sync() as first attempt to sync a collection OR
* from the mSyncConflictResolutionListener if the first attempt determines that a full-sync is required.
@@ -2022,8 +2085,8 @@ open class DeckPicker :
skipPrompt = conflict != null,
// TODO: why is this needed? 1f91b2868d
onDialogShown = ::refreshState,
- ) {
- handleNewSync(conflict, shouldFetchMedia())
+ ) { permission ->
+ handleNewSync(conflict, shouldFetchMedia(), meteredSyncPermission = permission)
}
}
@@ -2053,9 +2116,20 @@ open class DeckPicker :
*/
private fun tryShowStudyOptionsPanel(): Boolean {
val containerId = binding.studyoptionsFragment?.id ?: return false
- supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
- supportFragmentManager.commit {
- replace(containerId, StudyOptionsFragment())
+
+ // Close other panels before looking for restored study options.
+ supportFragmentManager.popBackStackImmediate(
+ null,
+ FragmentManager.POP_BACK_STACK_INCLUSIVE,
+ )
+
+ val studyOptions = fragment
+ if (studyOptions != null) {
+ studyOptions.refreshInterface()
+ } else {
+ supportFragmentManager.commitNow {
+ replace(containerId, StudyOptionsFragment())
+ }
}
return true
}
@@ -2287,17 +2361,13 @@ open class DeckPicker :
*/
fun deleteDeck(did: DeckId) =
launchCatchingTask {
- withProgress(resources.getString(CommonString.delete_deck)) {
- viewModel.deleteDeck(did).join()
- }
+ viewModel.deleteDeck(did).join()
}
@NeedsTest("14285: regression test to ensure UI is updated after this call")
fun rebuildFiltered(did: DeckId) {
launchCatchingTask {
- withProgress(resources.getString(CommonString.rebuild_filtered_deck)) {
- viewModel.rebuildFilteredDeck(did).join()
- }
+ viewModel.rebuildFilteredDeck(did).join()
updateDeckList()
tryShowStudyOptionsPanel()
}
@@ -2305,9 +2375,7 @@ open class DeckPicker :
private fun emptyFiltered(did: DeckId) {
launchCatchingTask {
- withProgress {
- viewModel.emptyFilteredDeck(did).join()
- }
+ viewModel.emptyFilteredDeck(did).join()
}
}
@@ -2512,3 +2580,11 @@ class CollectionLoadingErrorDialog :
val ActivityHomescreenBinding.studyoptionsFrame: FragmentContainerView?
get() = studyoptionsFragment
+
+private fun Context.progressMessage(progress: DeckPickerProgress): String =
+ when (progress) {
+ DeckPickerProgress.DELETING_DECK -> getString(CommonString.delete_deck)
+ DeckPickerProgress.REBUILDING_FILTERED_DECK -> getString(CommonString.rebuild_filtered_deck)
+ DeckPickerProgress.DELETING_EMPTY_CARDS -> TR.emptyCardsDeleting()
+ DeckPickerProgress.CREATING_BACKUP -> TR.sentenceCase.creatingBackup
+ }
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt
index 0bfe3d6b7a54..00b80c70c1d1 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt
@@ -19,6 +19,7 @@ import com.ichi2.anki.databinding.IncludeFloatingAddButtonBinding
import com.ichi2.anki.ui.DoubleTapListener
import com.ichi2.anki.ui.internationalization.sentenceCase
import timber.log.Timber
+import com.ichi2.anki.common.android.R as CommonR
class DeckPickerFloatingActionMenu(
private val context: Context,
@@ -42,7 +43,7 @@ class DeckPickerFloatingActionMenu(
private val addNoteIcon: Int = R.drawable.ic_add_note
// Add White Icon
- private val addWhiteIcon: Int = R.drawable.ic_add
+ private val addWhiteIcon: Int = CommonR.drawable.ic_add
var isFABOpen = false
private set
@@ -314,7 +315,6 @@ class DeckPickerFloatingActionMenu(
if (!isFABOpen) {
showFloatingActionMenu()
} else {
- closeFloatingActionMenu(applyRiseAndShrinkAnimation = false)
addNote()
}
return@setOnKeyListener true
@@ -396,7 +396,6 @@ class DeckPickerFloatingActionMenu(
if (!isFABOpen) {
showFloatingActionMenu()
} else {
- closeFloatingActionMenu(applyRiseAndShrinkAnimation = false)
Timber.d("configureFloatingActionsMenu::fabMain::onClickListener - Adding Note")
addNote()
}
@@ -413,6 +412,7 @@ class DeckPickerFloatingActionMenu(
* @see DeckPicker.addNote
*/
private fun addNote() {
+ closeFloatingActionMenu(applyRiseAndShrinkAnimation = false)
deckPicker.addNote()
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt
index 6a349bb2fb6c..9c738dafdb50 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt
@@ -11,6 +11,7 @@ import anki.search.searchNode
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.Flag.Companion.queryDisplayNames
+import com.ichi2.anki.common.utils.ext.flagLabels
import com.ichi2.anki.common.utils.ext.getStringOrNull
import com.ichi2.anki.ui.internationalization.sentenceCase
import kotlinx.serialization.KSerializer
@@ -173,12 +174,12 @@ private value class FlagLabels(
) {
value.put(flag.code.toString(), newName)
withCol {
- config.set("flagLabels", value)
+ config.flagLabels = value
}
}
companion object {
- suspend fun loadFromColConfig() = FlagLabels(withCol { config.getObject("flagLabels", JSONObject()) })
+ suspend fun loadFromColConfig() = FlagLabels(withCol { config.flagLabels })
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/FlagToDisplay.kt b/AnkiDroid/src/main/java/com/ichi2/anki/FlagToDisplay.kt
index 0b38539a4c2a..5e1107066946 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/FlagToDisplay.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/FlagToDisplay.kt
@@ -1,18 +1,6 @@
-/*
- * Copyright (c) 2023 Paul Tietz
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2023 Paul Tietz
+
package com.ichi2.anki
class FlagToDisplay(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt b/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt
index 7bfae56bf7f9..3cbc6bcd07b2 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt
@@ -20,6 +20,7 @@ import com.google.android.material.bottomnavigation.BottomNavigationView
import com.ichi2.anki.BottomNavController.NavigationItem
import com.ichi2.anki.browser.CardBrowserFragment
import com.ichi2.anki.browser.CardBrowserViewModel
+import com.ichi2.anki.browser.IdsFile
import com.ichi2.anki.common.analytics.Analytics
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.pages.Statistics
@@ -141,9 +142,10 @@ private fun ensureBrowserViewModel() {
deckPicker.viewModelStore,
CardBrowserViewModel.factory(
lastDeckIdRepository = AnkiDroidApp.instance.sharedPrefsLastDeckIdRepository,
- cacheDir = deckPicker.cacheDir,
+ idsFileDirectory = IdsFile.getDirectory(deckPicker),
options = null,
isFragmented = false,
+ initialDeckId = null,
),
deckPicker.defaultViewModelCreationExtras,
)[CardBrowserViewModel::class.java]
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
index 73df41266d34..2fa9b5732c65 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
@@ -24,10 +24,13 @@ 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.exception.SystemStorageException
import com.ichi2.anki.servicelayer.PreferenceUpgradeService
import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate
import com.ichi2.anki.startup.StoragePolicy
+import com.ichi2.anki.startup.ensureCollectionPathSet
import com.ichi2.anki.startup.folderToPersist
import com.ichi2.anki.ui.windows.permissions.InternetPermissionFragment
import com.ichi2.anki.ui.windows.permissions.LegacyNotificationsPermissionFragment
@@ -44,6 +47,25 @@ import timber.log.Timber
/** Utilities for launching the first activity (currently the DeckPicker) */
object InitialActivity {
+ /**
+ * Persists the collection path chosen by the required [StoragePermissionSet]'s
+ * [StoragePolicy] if the user has not decided yet; no-op otherwise.
+ *
+ * Safe to call without permissions: a policy deferring to the user leaves the
+ * [StorageDecision] undecided. Startup calls this once permissions are granted, so a
+ * deferred decision is recorded before the collection is opened.
+ *
+ * @see ensureCollectionPathSet
+ */
+ fun decideStorageIfUndecided(context: Context) {
+ try {
+ ensureCollectionPathSet(context)
+ } catch (e: SystemStorageException) {
+ // startup continues: getStartupFailureType reports the undecided state
+ Timber.w(e, "unable to choose a default collection path")
+ }
+ }
+
@CheckResult
fun getStartupFailureType(context: Context): StartupFailure? =
getStartupFailureType(context.sharedPrefs()) { CollectionHelper.isCurrentAnkiDroidDirAccessible(context) }
@@ -69,7 +91,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/JavaScriptSTT.kt b/AnkiDroid/src/main/java/com/ichi2/anki/JavaScriptSTT.kt
index ef2d663b772a..04f9ddafe5ff 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/JavaScriptSTT.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/JavaScriptSTT.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2023 krmanik
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2023 krmanik
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/JavaScriptTTS.kt b/AnkiDroid/src/main/java/com/ichi2/anki/JavaScriptTTS.kt
index 2b8752595af9..e661bb4ca61d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/JavaScriptTTS.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/JavaScriptTTS.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2021 mikunimaru
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2021 mikunimaru
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/LanguageUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/LanguageUtils.kt
index 407c87ae9709..cc628b75dd56 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/LanguageUtils.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/LanguageUtils.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2021 mikunimaru
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2021 mikunimaru
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt
index 2037dec39c2f..39889d4771f3 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt
@@ -1,18 +1,6 @@
-/*
- * Copyright (c) 2021 Akshay Jadhav
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2021 Akshay Jadhav
+
package com.ichi2.anki
import android.content.ClipDescription
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MetaDB.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MetaDB.kt
index 1b1e19adb5c6..5a3ebd0b9b97 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/MetaDB.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/MetaDB.kt
@@ -1,4 +1,6 @@
-//noinspection MissingCopyrightHeader #8659
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileComment: Added by nobnago in 2011 (a4b836e83a), without a file-level license notice.
+
package com.ichi2.anki
import android.content.Context
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt
index 339f8b69ccc4..b4c4adbed9b4 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt
@@ -53,6 +53,7 @@ import com.ichi2.anki.dialogs.help.HelpDialog
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.anki.workarounds.FullDraggableContainerFix
import com.ichi2.utils.IntentUtil
+import com.ichi2.widget.WidgetPermissionReceiver
import timber.log.Timber
import com.ichi2.anki.common.android.R as CommonR
@@ -221,7 +222,7 @@ abstract class NavigationDrawerActivity(
drawerLayout.addDrawerListener(drawerToggle)
enablePostShortcut(this)
- val intent = Intent("com.ichi2.widget.UPDATE_WIDGET").setClassName("com.ichi2.widget", "WidgetPermissionReceiver")
+ val intent = Intent(this, WidgetPermissionReceiver::class.java).setAction("com.ichi2.widget.UPDATE_WIDGET")
this.sendBroadcast(intent)
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorActivity.kt
index 21f83cc02351..1bf05a082800 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorActivity.kt
@@ -8,6 +8,7 @@ import android.os.Build
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
+import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
@@ -96,7 +97,9 @@ class NoteEditorActivity :
val hasPreviewerPane: Boolean
get() = previewerFrame != null
- private lateinit var binding: ActivityNoteEditorBinding
+ @VisibleForTesting
+ internal lateinit var binding: ActivityNoteEditorBinding
+ private set
private fun setupEdgeToEdge() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -185,11 +188,11 @@ class NoteEditorActivity :
ResizablePaneManager(
parentLayout = parentLayout,
divider = divider,
- leftPane = noteEditorPane,
- rightPane = previewerPane,
+ startPane = noteEditorPane,
+ endPane = previewerPane,
sharedPrefs = Prefs.getUiConfig(this),
- leftPaneWeightKey = PREF_NOTE_EDITOR_PANE_WEIGHT,
- rightPaneWeightKey = PREF_PREVIEWER_PANE_WEIGHT,
+ startPaneWeightKey = PREF_NOTE_EDITOR_PANE_WEIGHT,
+ endPaneWeightKey = PREF_PREVIEWER_PANE_WEIGHT,
)
if (isPreviewerVisible) {
// Defer previewer loading to avoid blocking onCreate
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
index 727b59e8085c..baee819a2a3a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
@@ -70,7 +70,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
-import anki.config.ConfigKey
import anki.notes.NoteFieldsCheckResponse
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -99,8 +98,9 @@ import com.ichi2.anki.common.utils.android.showThemedToast
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
import com.ichi2.anki.common.utils.ext.AddingDefaultsMode
import com.ichi2.anki.common.utils.ext.addingDefaultsMode
+import com.ichi2.anki.common.utils.ext.currentDeckId
import com.ichi2.anki.common.utils.ext.getParcelableExtraCompat
-import com.ichi2.anki.common.utils.ext.ifZero
+import com.ichi2.anki.common.utils.ext.pasteImagesAsPng
import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat
import com.ichi2.anki.compat.setTooltipTextCompat
import com.ichi2.anki.databinding.FragmentNoteEditorBinding
@@ -118,7 +118,6 @@ import com.ichi2.anki.libanki.CardOrdinal
import com.ichi2.anki.libanki.Collection
import com.ichi2.anki.libanki.Consts
import com.ichi2.anki.libanki.DeckId
-import com.ichi2.anki.libanki.Decks.Companion.CURRENT_DECK
import com.ichi2.anki.libanki.Field
import com.ichi2.anki.libanki.Fields
import com.ichi2.anki.libanki.Note
@@ -159,6 +158,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
@@ -281,6 +281,9 @@ class NoteEditorFragment :
@get:VisibleForTesting
var deckId: DeckId = 0
private set
+
+ /** Local selection while adding; [Collection.addNote] remembers it after a successful save. */
+ private var selectedNoteTypeId: NoteTypeId? = null
private var allNoteTypeIds: List? = null
private val customViewIds = ArrayList()
@@ -539,6 +542,8 @@ class NoteEditorFragment :
caller = fromValue(savedInstanceState.getInt(CALLER_KEY))
addNote = savedInstanceState.getBoolean("addNote")
deckId = savedInstanceState.getLong("did")
+ selectedNoteTypeId =
+ if (savedInstanceState.containsKey("noteTypeId")) savedInstanceState.getLong("noteTypeId") else null
selectedTags = savedInstanceState.getStringArrayList("tags")
reloadRequired = savedInstanceState.getBoolean(EXTRA_RELOAD_REQUIRED)
multimediaController.onRestoreInstanceState(savedInstanceState)
@@ -613,6 +618,7 @@ class NoteEditorFragment :
savedInstanceState.putInt(CALLER_KEY, caller.value)
savedInstanceState.putBoolean("addNote", addNote)
savedInstanceState.putLong("did", deckId)
+ selectedNoteTypeId?.let { savedInstanceState.putLong("noteTypeId", it) }
savedInstanceState.putBoolean(EXTRA_NOTE_CHANGED, changed)
savedInstanceState.putBoolean(EXTRA_RELOAD_REQUIRED, reloadRequired)
savedInstanceState.putIntegerArrayList("customViewIds", customViewIds)
@@ -757,14 +763,15 @@ class NoteEditorFragment :
deckId = requireArguments().getLong(EXTRA_DID, deckId)
if (addNote) {
- // When adding and if we didn't receive a valid deck id or it's the 'Default' deck,
- // use the recommended deck for adding
- deckId = deckId.ifZero { col.defaultsForAdding().deckId }
-
- // Also guard against adding to a filtered deck
+ // Initialize both selections from the same defaults
+ // https://github.com/ankitects/anki/blob/754ce3a25f608010c0249e074e5d7fe95bda035f/qt/aqt/addcards_legacy.py#L91-L108
+ val defaults = col.defaultsForAdding()
+ if (selectedNoteTypeId?.let { col.notetypes.get(it) } == null) {
+ selectedNoteTypeId = defaults.notetypeId
+ }
val deck = col.decks.getLegacy(deckId)
if (deck == null || deck.isFiltered) {
- deckId = col.defaultsForAdding().deckId
+ deckId = defaults.deckId
}
} else {
// When editing we always have a valid currentEditCard. Check to see if it's from a normal
@@ -1296,6 +1303,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 +1349,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 +1407,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.
@@ -1818,7 +1864,7 @@ class NoteEditorFragment :
}
/** Determines whether pasted images should be handled as PNG format. **/
- private suspend fun shouldPasteAsPng() = withCol { config.getBool(ConfigKey.Bool.PASTE_IMAGES_AS_PNG) }
+ private suspend fun shouldPasteAsPng() = withCol { config.pasteImagesAsPng }
val currentFields: Fields
get() = editorNote!!.notetype.fields
@@ -2167,7 +2213,7 @@ class NoteEditorFragment :
}
}
- val currentDeckId = getColUnsafe.config.get(CURRENT_DECK) ?: 1L
+ val currentDeckId = getColUnsafe.config.currentDeckId
return if (getColUnsafe.decks.isFiltered(currentDeckId)) {
/*
* If the deck in mCurrentDid is a filtered (dynamic) deck, then we can't create cards in it,
@@ -2197,15 +2243,7 @@ class NoteEditorFragment :
note: Note?,
changeType: FieldChangeType,
) {
- editorNote =
- if (note == null || addNote) {
- getColUnsafe.run {
- val notetype = notetypes.current()
- Note.fromNotetypeId(this@run, notetype.id)
- }
- } else {
- note
- }
+ editorNote = note ?: Note.fromNotetypeId(getColUnsafe, checkNotNull(selectedNoteTypeId))
if (selectedTags == null) {
selectedTags = editorNote!!.tags
}
@@ -2488,14 +2526,14 @@ class NoteEditorFragment :
if (hasIcon) {
etIconLayout.isErrorEnabled = false
} else {
- etIconLayout.setErrorIfChanged(getString(R.string.note_editor_toolbar_icon_requirement_error))
+ etIconLayout.setErrorIfChanged(getString(CommonString.note_editor_toolbar_icon_requirement_error))
}
if (hasHtml) {
etBeforeLayout.isErrorEnabled = false
etAfterLayout.isErrorEnabled = false
} else {
- val message = getString(R.string.before_after_text_requirement_error)
+ val message = getString(CommonString.before_after_text_requirement_error)
etBeforeLayout.setErrorIfChanged(message)
etAfterLayout.setErrorIfChanged(message)
}
@@ -2657,24 +2695,18 @@ class NoteEditorFragment :
}
private fun changeNoteType(newId: NoteTypeId) {
- val oldNoteTypeId = getColUnsafe.notetypes.current().id
Timber.i("Changing note type to '%d", newId)
- if (oldNoteTypeId == newId) {
+ if (editorNote!!.noteTypeId == newId) {
return
}
- val noteType = getColUnsafe.notetypes.get(newId)
- if (noteType == null) {
+ if (getColUnsafe.notetypes.get(newId) == null) {
Timber.w("New note type %s not found, not changing note type", newId)
return
}
- getColUnsafe.notetypes.setCurrent(noteType)
- val currentDeck = getColUnsafe.decks.current()
- currentDeck.put("mid", newId)
- getColUnsafe.decks.save(currentDeck)
-
+ selectedNoteTypeId = newId
// Preserves the editor's deck when the new note type has no remembered destination.
getColUnsafe.defaultDeckForNoteType(newId)?.let { deckId = it }
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteFieldsCheckResult.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteFieldsCheckResult.kt
index b3eeef5caf56..883fbb664dd1 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteFieldsCheckResult.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteFieldsCheckResult.kt
@@ -1,19 +1,5 @@
-/*
- * Copyright (c) 2024 Ashish Yadav
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2024 Ashish Yadav
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NotificationChannel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationChannel.kt
index 7d14741477a5..0425f08214bf 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NotificationChannel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationChannel.kt
@@ -11,13 +11,14 @@ import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import timber.log.Timber
+import kotlin.time.Duration.Companion.milliseconds
/**
* Minimum delay between notifications to avoid reaching the
* maximum update rate, which currently is 5 updates per second.
* https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/core/java/android/app/NotificationManager.java;l=675;drc=e13ace5dabea6d65c05dbfd9d19dc697a687d7be
*/
-const val NOTIFICATION_MIN_DELAY_MS = 200L
+val NOTIFICATION_MIN_DELAY = 200.milliseconds
/**
* Create or update all the notification channels for the app
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NotificationScheduling.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationScheduling.kt
new file mode 100644
index 000000000000..f997aaed488d
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationScheduling.kt
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2026 Eric Li
+
+package com.ichi2.anki
+
+import android.content.Context
+import com.ichi2.anki.reviewreminders.ReviewReminderAlarmManager
+
+/**
+ * Schedules all notifications defined by AnkiDroid.
+ * Since notifications are deleted when the device restarts, this method should be called on
+ * device start-up, on app start-up, etc.
+ * To extend the notifications created by AnkiDroid, add more functionality to the body of this method.
+ */
+suspend fun scheduleAllNotifications(context: Context) {
+ // currently, the only scheduled notifications supported by AnkiDroid are review reminder notifications
+ ReviewReminderAlarmManager.scheduleAllEnabledReviewReminderNotifications(context)
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/OnContextAndLongClickListener.kt b/AnkiDroid/src/main/java/com/ichi2/anki/OnContextAndLongClickListener.kt
index e97e75d95520..69db8430a825 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/OnContextAndLongClickListener.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/OnContextAndLongClickListener.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2024 Sanjay Sargam
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2024 Sanjay Sargam
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/OnPageFinishedCallback.kt b/AnkiDroid/src/main/java/com/ichi2/anki/OnPageFinishedCallback.kt
index 0a966ef0e654..fdafd9fc86e1 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/OnPageFinishedCallback.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/OnPageFinishedCallback.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2022 Kshitij Patil
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2022 Kshitij Patil
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ReadText.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ReadText.kt
index caf2b949bdf1..e77befd592cf 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ReadText.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ReadText.kt
@@ -1,18 +1,6 @@
-/*
- * Copyright (c) 2011 Norbert Nagold
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2011 Norbert Nagold
+
package com.ichi2.anki
import android.annotation.SuppressLint
@@ -45,6 +33,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import java.lang.ref.WeakReference
import java.util.Locale
+import kotlin.time.Duration.Companion.milliseconds
object ReadText {
@get:VisibleForTesting(otherwise = VisibleForTesting.NONE)
@@ -182,7 +171,7 @@ object ReadText {
}
}
// Give the user a chance to preview the card. The delay is cancelled with the reviewer.
- delay(500)
+ delay(500.milliseconds)
try {
dialog.show()
} catch (e: BadTokenException) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
index 0835c9141915..b09eb89aa188 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
@@ -87,6 +87,7 @@ import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.common.utils.android.HandlerUtils.executeFunctionWithDelay
import com.ichi2.anki.common.utils.android.HandlerUtils.getDefaultLooper
import com.ichi2.anki.common.utils.android.showThemedToast
+import com.ichi2.anki.common.utils.ext.showRemainingDueCounts
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.Collection
@@ -1846,7 +1847,7 @@ open class Reviewer :
override fun restoreCollectionPreferences(col: Collection) {
super.restoreCollectionPreferences(col)
- showRemainingCardCount = col.config.get("dueCounts") ?: true
+ showRemainingCardCount = col.config.showRemainingDueCounts
stopTimerOnAnswer = col.decks.configDictForDeckId(col.decks.current().id).stopTimerOnAnswer
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt
index 37bb1b300c5d..139958972664 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, CommonString.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/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
index dbfbab4c7e60..73023aa76ccc 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
@@ -2,9 +2,11 @@
package com.ichi2.anki
+import android.annotation.SuppressLint
import android.app.Activity
import android.os.Bundle
import android.text.Html
+import android.text.Layout
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
@@ -14,6 +16,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
+import android.widget.ScrollView
import android.widget.TextView
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -27,6 +30,7 @@ import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.doOnAttach
+import androidx.core.view.doOnLayout
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
@@ -86,6 +90,12 @@ class StudyOptionsFragment :
private var fragmented = false
+ /** Deck whose scroll position is held by the current or saved view. */
+ // TODO: Include deckId in StudyOptionsState, and stop resetting scroll based on this
+ private var displayedDeckId: DeckId? = null
+
+ private var descriptionLayoutBeforeResizing: DescriptionLayoutSettings? = null
+
private val buttonClickListener =
View.OnClickListener { v: View ->
if (v.id == R.id.studyoptions_start) {
@@ -99,6 +109,14 @@ class StudyOptionsFragment :
}
}
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ displayedDeckId = savedInstanceState?.getLong(STATE_DISPLAYED_DECK_ID)?.takeIf { it != 0L }
+ // Subscribe once per fragment: returning from another panel can recreate its view.
+ ChangeManager.subscribe(this)
+ viewModel.flowOfState.launchCollectionInLifecycleScope(::rebuildUi)
+ }
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -106,9 +124,9 @@ class StudyOptionsFragment :
): View? {
Timber.i("onCreateView()")
val studyOptionsView = inflater.inflate(R.layout.fragment_study_options, container, false)
+ descriptionLayoutBeforeResizing = null
fragmented = requireActivity().javaClass != StudyOptionsActivity::class.java
initAllContentViews(studyOptionsView)
- ChangeManager.subscribe(this)
return studyOptionsView
}
@@ -122,10 +140,38 @@ class StudyOptionsFragment :
requireAnkiActivity().setToolbarText(title = "")
}
setupContentInsets(view)
- viewModel.flowOfState.launchCollectionInLifecycleScope(::rebuildUi)
refreshInterface()
}
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ displayedDeckId?.let { outState.putLong(STATE_DISPLAYED_DECK_ID, it) }
+ }
+
+ /** Uses cheaper wrapping during a divider drag, then restores the view's original settings. */
+ fun onResizingChanged(isResizing: Boolean) {
+ if (view == null) return
+ val description = textDeckDescription
+ if (isResizing) {
+ if (descriptionLayoutBeforeResizing != null) return
+ descriptionLayoutBeforeResizing =
+ DescriptionLayoutSettings(
+ breakStrategy = description.breakStrategy,
+ hyphenationFrequency = description.hyphenationFrequency,
+ )
+ // Avoid whole-paragraph optimization while the width changes. Setters invalidate the
+ // text layout, so only change these settings at the start and end of the gesture.
+ @SuppressLint("WrongConstant")
+ description.breakStrategy = Layout.BREAK_STRATEGY_SIMPLE
+ description.hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
+ } else {
+ val originalSettings = descriptionLayoutBeforeResizing ?: return
+ description.breakStrategy = originalSettings.breakStrategy
+ description.hyphenationFrequency = originalSettings.hyphenationFrequency
+ descriptionLayoutBeforeResizing = null
+ }
+ }
+
/** Keeps the content clear of the system bars, display cutout and keyboard. */
private fun setupContentInsets(view: View) {
view.doOnApplyWindowInsets { v, insets, _ ->
@@ -326,6 +372,12 @@ class StudyOptionsFragment :
initAllContentViews(studyOptionsView)
+ if (displayedDeckId != null && displayedDeckId != viewModel.selectedDeckId) {
+ // ScrollView restores its saved position during its first layout after a panel returns.
+ studyOptionsView.findViewById(R.id.studyoptions_scrollview).doOnLayout { it.scrollTo(0, 0) }
+ }
+ displayedDeckId = viewModel.selectedDeckId
+
updateDeckNameView(state)
when (state) {
@@ -339,6 +391,7 @@ class StudyOptionsFragment :
buttonStart.visibility = View.VISIBLE
buttonStart.text = TR.sentenceCase.customStudy
} else {
+ deckInfoLayout.visibility = View.VISIBLE
buttonStart.visibility = View.GONE
}
}
@@ -440,7 +493,14 @@ class StudyOptionsFragment :
textDeckName.text = nameBuilder.toString()
}
+ private data class DescriptionLayoutSettings(
+ val breakStrategy: Int,
+ val hyphenationFrequency: Int,
+ )
+
companion object {
+ private const val STATE_DISPLAYED_DECK_ID = "displayedDeckId"
+
/**
* Identifier for a fragment result request to study(open the reviewer). Activities using
* this fragment need to handle this request and initialize the study screen as they see fit.
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt
index 58d1e9a7b9fb..528abc126751 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import anki.collection.OpChanges
import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.CollectionManager.withOpenColOrNull
import com.ichi2.anki.libanki.Collection
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.observability.undoableOp
@@ -43,8 +44,7 @@ class StudyOptionsViewModel : ViewModel() {
* the collection.
*/
suspend fun refreshData() {
- if (!CollectionManager.isOpenUnsafe()) return
- withCol { updateStateFromCollection() }
+ withOpenColOrNull { updateStateFromCollection() }
}
suspend fun rebuildCram() {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
index 30bfe1e35664..bce8c357ca36 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
@@ -5,6 +5,7 @@ package com.ichi2.anki
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
+import androidx.work.NetworkType
import anki.collection.Progress
import anki.sync.SyncCollectionResponse
import anki.sync.syncAuth
@@ -18,6 +19,8 @@ import com.ichi2.anki.observability.ChangeManager.notifySubscribersAllValuesChan
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.settings.enums.ShouldFetchMedia
import com.ichi2.anki.snackbar.showSnackbar
+import com.ichi2.anki.sync.MeteredSyncPermission
+import com.ichi2.anki.sync.MeteredSyncPolicy
import com.ichi2.anki.sync.SyncAuth
import com.ichi2.anki.sync.fullUploadOrDownload
import com.ichi2.anki.sync.syncCollection
@@ -37,6 +40,7 @@ import net.ankiweb.rsdroid.Backend
import net.ankiweb.rsdroid.exceptions.BackendInterruptedException
import net.ankiweb.rsdroid.exceptions.BackendSyncException
import timber.log.Timber
+import kotlin.time.Duration.Companion.milliseconds
object SyncPreferences {
const val CURRENT_SYNC_URI = "currentSyncUri"
@@ -89,17 +93,21 @@ fun millisecondsSinceLastSync() = TimeManager.time.intTimeMS() - Prefs.lastSyncT
fun DeckPicker.handleNewSync(
conflict: ConflictResolution?,
syncMedia: Boolean,
+ meteredSyncPermission: MeteredSyncPermission = MeteredSyncPermission.USE_PREFERENCES,
) {
val auth = syncAuth() ?: return
+ val mediaNetworkType = MeteredSyncPolicy.getNetworkTypeRequiredForSync(forMedia = true, permission = meteredSyncPermission)
val deckPicker = this
launchCatchingTask {
try {
try {
when (conflict) {
- ConflictResolution.FULL_DOWNLOAD -> handleDownload(deckPicker, auth, deckPicker.mediaUsnOnConflict)
- ConflictResolution.FULL_UPLOAD -> handleUpload(deckPicker, auth, deckPicker.mediaUsnOnConflict)
+ ConflictResolution.FULL_DOWNLOAD ->
+ handleDownload(deckPicker, auth, deckPicker.mediaUsnOnConflict, deckPicker.mediaNetworkTypeOnConflict)
+ ConflictResolution.FULL_UPLOAD ->
+ handleUpload(deckPicker, auth, deckPicker.mediaUsnOnConflict, deckPicker.mediaNetworkTypeOnConflict)
null -> {
- handleNormalSync(deckPicker, auth, syncMedia)
+ handleNormalSync(deckPicker, auth, syncMedia, mediaNetworkType)
}
}
} catch (exc: BackendSyncException.BackendSyncAuthFailedException) {
@@ -107,9 +115,23 @@ fun DeckPicker.handleNewSync(
updateLogin("", "")
throw exc
} catch (exc: BackendSyncException) {
- if (!exc.isClockOffException()) throw exc
- showSyncErrorDialog(SyncErrorDialog.Type.DIALOG_SYNC_CLOCK_OFF, exc.localizedMessage)
- return@launchCatchingTask
+ if (exc.isClockOffException()) {
+ showSyncErrorDialog(
+ SyncErrorDialog.Type.DIALOG_SYNC_CLOCK_OFF,
+ exc.localizedMessage,
+ )
+ return@launchCatchingTask
+ }
+
+ if (exc.isCollectionTooLargeException()) {
+ showSyncErrorDialog(
+ SyncErrorDialog.Type.DIALOG_COLLECTION_TOO_LARGE,
+ exc.localizedMessage,
+ )
+ return@launchCatchingTask
+ }
+
+ throw exc
}
withCol { notetypes.clearCache() }
notifySubscribersAllValuesChanged(deckPicker)
@@ -130,6 +152,12 @@ private fun BackendSyncException.isClockOffException(): Boolean =
false
}
+private val collectionTooLargeDetailsRegex =
+ Regex("""\b\d+(?:\.\d{2})?(?: MB)? > \d+(?:\.\d{2})?(?: MB)?\b""")
+
+private fun BackendSyncException.isCollectionTooLargeException(): Boolean =
+ message?.let { collectionTooLargeDetailsRegex.containsMatchIn(it) } == true
+
fun updateLogin(
username: String,
hkey: String,
@@ -147,6 +175,7 @@ private suspend fun handleNormalSync(
deckPicker: DeckPicker,
auth: SyncAuth,
syncMedia: Boolean,
+ mediaNetworkType: NetworkType,
) {
Timber.i("Sync: Normal collection sync")
var auth2 = auth
@@ -190,20 +219,21 @@ private suspend fun handleNormalSync(
deckPicker.showSyncLogMessage(message, output.serverMessage)
deckPicker.refreshState()
if (syncMedia) {
- SyncMediaWorker.start(deckPicker, auth2)
+ SyncMediaWorker.start(deckPicker, auth2, mediaNetworkType)
}
}
SyncCollectionResponse.ChangesRequired.FULL_DOWNLOAD -> {
- handleDownload(deckPicker, auth2, mediaUsn)
+ handleDownload(deckPicker, auth2, mediaUsn, mediaNetworkType)
}
SyncCollectionResponse.ChangesRequired.FULL_UPLOAD -> {
- handleUpload(deckPicker, auth2, mediaUsn)
+ handleUpload(deckPicker, auth2, mediaUsn, mediaNetworkType)
}
SyncCollectionResponse.ChangesRequired.FULL_SYNC -> {
deckPicker.mediaUsnOnConflict = mediaUsn
+ deckPicker.mediaNetworkTypeOnConflict = mediaNetworkType
deckPicker.showSyncErrorDialog(SyncErrorDialog.Type.DIALOG_SYNC_CONFLICT_RESOLUTION)
}
@@ -230,6 +260,7 @@ private suspend fun handleDownload(
deckPicker: DeckPicker,
auth: SyncAuth,
mediaUsn: Int?,
+ mediaNetworkType: NetworkType,
) {
Timber.i("Sync: Full collection download requested")
deckPicker.withProgress(
@@ -260,7 +291,7 @@ private suspend fun handleDownload(
}
deckPicker.refreshState()
if (mediaUsn != null) {
- SyncMediaWorker.start(deckPicker, auth)
+ SyncMediaWorker.start(deckPicker, auth, mediaNetworkType)
}
}
@@ -272,6 +303,7 @@ private suspend fun handleUpload(
deckPicker: DeckPicker,
auth: SyncAuth,
mediaUsn: Int?,
+ mediaNetworkType: NetworkType,
) {
Timber.i("Sync: Full collection upload requested")
deckPicker.withProgress(
@@ -293,7 +325,7 @@ private suspend fun handleUpload(
}
deckPicker.refreshState()
if (mediaUsn != null) {
- SyncMediaWorker.start(deckPicker, auth)
+ SyncMediaWorker.start(deckPicker, auth, mediaNetworkType)
}
}
Timber.i("Full Upload Completed")
@@ -348,7 +380,7 @@ suspend fun monitorMediaSync(deckPicker: DeckPicker) {
}
val text = resp.progress.run { "$added\n$removed\n$checked" }
dialog.setMessage(text)
- delay(100)
+ delay(100.milliseconds)
}
showMessage(if (isAborted) TR.syncMediaAborted() else TR.syncMediaComplete())
} catch (_: BackendInterruptedException) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/TtsParser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/TtsParser.kt
index 7366b5356f4d..bb008a03881a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/TtsParser.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/TtsParser.kt
@@ -1,4 +1,5 @@
-//noinspection MissingCopyrightHeader #8659
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki
import com.ichi2.anki.libanki.TTSTag
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
index 118f939228f7..977fbc5851de 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
@@ -28,6 +28,7 @@ import com.ichi2.anki.i18n.normalize
import com.ichi2.anki.i18n.toAnkiTwoLetterCode
import com.ichi2.anki.libanki.TemplateManager
import com.ichi2.anki.libanki.TtsVoice
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -242,16 +243,12 @@ object TtsVoices {
* @param engine the package name of the TTS engine to use, or `null` to use the user's
* default engine
* @return a usable [TextToSpeech] instance, or `null` if the [TextToSpeech.OnInitListener]
- * returns [TextToSpeech.ERROR]
+ * returns [TextToSpeech.ERROR] or construction fails
+ * @throws CancellationException if creation is cancelled
*/
suspend fun createTts(engine: String? = null) =
suspendCancellableCoroutine { continuation ->
var textToSpeech: TextToSpeech? = null
- continuation.invokeOnCancellation {
- Timber.v("TTS creation cancelled")
- textToSpeech?.stop()
- textToSpeech?.shutdown()
- }
Timber.v("begin TTS creation (engine: %s)", engine)
val onInit =
TextToSpeech.OnInitListener { status ->
@@ -272,11 +269,29 @@ object TtsVoices {
// may be expected to disappear, as it would cause a memory leak. Hence
// we pass it the application as context.
textToSpeech =
- if (engine == null) {
- TextToSpeech(appContext, onInit)
- } else {
- TextToSpeech(appContext, onInit, engine)
+ try {
+ if (engine == null) {
+ TextToSpeech(appContext, onInit)
+ } else {
+ TextToSpeech(appContext, onInit, engine)
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.w(e, "Unable to initialize TTS engine: %s", engine)
+ continuation.resume(null)
+ null
}
+ // Register after construction so cancellation cannot miss the new instance.
+ continuation.invokeOnCancellation {
+ Timber.v("TTS creation cancelled")
+ // Engine calls can block.
+ // Launch on a scope which survives cancellation of the activity.
+ applicationScope.launch(ioDispatcher) {
+ textToSpeech?.stop()
+ textToSpeech?.shutdown()
+ }
+ }
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Undo.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Undo.kt
index 09a0f6ef54e5..fc612c0a1530 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Undo.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Undo.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2022 Ankitects Pty Ltd
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2022 Ankitects Pty Ltd
package com.ichi2.anki
@@ -54,7 +41,7 @@ suspend fun tryRedo(): String {
return if (changes.operation.isEmpty()) {
TR.actionsNothingToRedo()
} else {
- TR.undoRedoAction(changes.operation)
+ TR.undoActionRedone(changes.operation)
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/WhiteboardMultiTouchMethods.kt b/AnkiDroid/src/main/java/com/ichi2/anki/WhiteboardMultiTouchMethods.kt
index d7b41756247c..0b592b84d8af 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/WhiteboardMultiTouchMethods.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/WhiteboardMultiTouchMethods.kt
@@ -1,18 +1,6 @@
-/*
- Copyright (c) 2021 Akshay Jadhav
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2021 Akshay Jadhav
- 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
/**
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/analytics/AnrTrace.kt b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/AnrTrace.kt
new file mode 100644
index 000000000000..f58c6be6dd9d
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/AnrTrace.kt
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.analytics
+
+import org.apache.commons.io.input.BoundedReader
+import java.io.Reader
+
+/**
+ * Reads the main thread's Java/Kotlin frames from an Android ANR dump.
+ *
+ * Preserves native-method markers, but omits native backtraces, lock details and other threads.
+ * Bounds both the input scanned and the captured frames; returns null if no frames are recognized.
+ */
+internal fun Reader.readAnrMainThreadStackTrace(): Array? {
+ val lines = BoundedReader(this, MAX_TRACE_SCAN_CHARS).buffered().lineSequence()
+ val frames = mutableListOf()
+ var inMainThread = false
+ var frameChars = 0
+ for (line in lines) {
+ if (!inMainThread) {
+ inMainThread = line.startsWith("\"main\" ")
+ continue
+ }
+ if (line.isBlank() || line.startsWith('"') || line.startsWith("-----")) break
+ val frame = line.toAnrStackTraceElement() ?: continue
+ if (frameChars + line.length > MAX_STACK_TRACE_CHARS) break
+ frames.add(frame)
+ frameChars += line.length
+ if (frames.size == MAX_STACK_TRACE_FRAMES) break
+ }
+ return frames.takeIf { it.isNotEmpty() }?.toTypedArray()
+}
+
+private fun String.toAnrStackTraceElement(): StackTraceElement? {
+ val match = STACK_FRAME.matchEntire(this) ?: return null
+ val (className, methodName, source) = match.destructured
+ return when {
+ source.equals("Native method", ignoreCase = true) -> StackTraceElement(className, methodName, null, -2)
+ source == "Unknown Source" || source == "Unresolved" -> StackTraceElement(className, methodName, null, -1)
+ else ->
+ StackTraceElement(
+ className,
+ methodName,
+ source.substringBeforeLast(':').ifEmpty { null },
+ source.substringAfterLast(':', "").toIntOrNull() ?: -1,
+ )
+ }
+}
+
+private val STACK_FRAME = Regex("""^\s+at (.+)\.([^.()]+)\(([^()]*)\)$""")
+private const val MAX_TRACE_SCAN_CHARS = 256 * 1024
+private const val MAX_STACK_TRACE_CHARS = 16 * 1024
+private const val MAX_STACK_TRACE_FRAMES = 64
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/analytics/PreviousAnrReporter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/PreviousAnrReporter.kt
new file mode 100644
index 000000000000..393815ddadfe
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/PreviousAnrReporter.kt
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.analytics
+
+import android.app.ActivityManager
+import android.app.ApplicationExitInfo
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.annotation.WorkerThread
+import com.ichi2.anki.AnkiDroidApp
+import com.ichi2.anki.common.coroutines.applicationScope
+import com.ichi2.anki.common.crashreporting.CrashReportService
+import com.ichi2.anki.common.utils.ext.requireSystemService
+import com.ichi2.anki.exception.AnrReportException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.time.Instant
+
+/**
+ * Launches reporting of a previous run's ANR in the background during main-process startup on Android 11+.
+ * Logs the reporting duration using the standard startup timing.
+ */
+internal fun AnkiDroidApp.launchPreviousAnrReporting() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
+ applicationScope.launch(Dispatchers.IO) {
+ try {
+ setup("reportPreviousAnr") {
+ reportPreviousAnr()
+ }
+ } catch (_: Exception) {
+ // setup() already logged the failure; this diagnostic must not interrupt startup.
+ }
+ }
+}
+
+/**
+ * Reports the latest main-process exit caused by an ANR as an [AnrReportException].
+ *
+ * Uses the captured main-thread stack; other report metadata describes this reporting launch.
+ *
+ * ANRs that recover before the process exits for another reason are not included.
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+@WorkerThread
+context(context: AnkiDroidApp)
+internal fun reportPreviousAnr() {
+ // Android's exit history is shared across profiles, so the processed marker must be too.
+ val state = SharedPreferencesAnrReportState(context.baseContext)
+ val since = state.lastProcessedTimestamp
+
+ // ApplicationExitInfo has no app version. Do not attribute an older build's ANR to this one.
+ @Suppress("DEPRECATION")
+ val updatedAt = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
+ val activityManager = context.requireSystemService()
+ val latestAnrExitInfo =
+ activityManager
+ .getHistoricalProcessExitReasons(null, 0, 0)
+ .filter {
+ it.reason == ApplicationExitInfo.REASON_ANR &&
+ it.processName == context.packageName &&
+ it.timestamp > since && it.timestamp >= updatedAt
+ }.maxByOrNull { it.timestamp } ?: return
+
+ // Avoid processing the same ANR on every launch.
+ if (!state.markProcessed(latestAnrExitInfo.timestamp)) return
+ val anrReport = latestAnrExitInfo.readAnrReport() ?: return
+
+ // TODO(#22027): Evaluate API 37 AnrInfo metadata (ANR type, timeout, user perceptibility)
+ CrashReportService.sendExceptionReport(
+ anrReport,
+ origin = "PreviousAnrReporter",
+ additionalInfo = "Previous ANR: process exited at ${latestAnrExitInfo.instant}.",
+ onlyIfSilent = true,
+ )
+}
+
+/** The timestamp of the process's death. */
+@get:RequiresApi(Build.VERSION_CODES.R)
+private val ApplicationExitInfo.instant: Instant
+ get() = Instant.ofEpochMilli(timestamp)
+
+@RequiresApi(Build.VERSION_CODES.R)
+private fun ApplicationExitInfo.readAnrReport(): AnrReportException? =
+ traceInputStream?.reader()?.use { AnrReportException(it.readAnrMainThreadStackTrace()) }
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/analytics/SharedPreferencesAnrReportState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/SharedPreferencesAnrReportState.kt
new file mode 100644
index 000000000000..8cfca2049c91
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/SharedPreferencesAnrReportState.kt
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.analytics
+
+import android.annotation.SuppressLint
+import android.content.Context
+
+internal class SharedPreferencesAnrReportState(
+ private val context: Context,
+) {
+ private val preferences get() = context.getSharedPreferences("historical_anrs", Context.MODE_PRIVATE)
+
+ val lastProcessedTimestamp: Long
+ get() = preferences.getLong(TIMESTAMP_KEY, 0L)
+
+ @SuppressLint("UseKtx") // The commit result decides whether it is safe to report this exit.
+ fun markProcessed(timestamp: Long): Boolean = preferences.edit().putLong(TIMESTAMP_KEY, timestamp).commit()
+}
+
+private const val TIMESTAMP_KEY = "checked_through"
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/backend/AnkiBackendLanguage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/backend/AnkiBackendLanguage.kt
new file mode 100644
index 000000000000..19858bccc258
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/backend/AnkiBackendLanguage.kt
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.backend
+
+import java.util.IllformedLocaleException
+import java.util.Locale
+
+/**
+ * A well-formed Unicode language identifier used by Anki's backend for translations.
+ *
+ * `rslib` parses preferred languages with `unic_langid::LanguageIdentifier`: a language, optional
+ * script and region, and variants, separated by `-` or `_`.
+ *
+ * Locale extensions are not supported.
+ *
+ * Identifiers that fail to parse are ignored, and English is always included as a fallback.
+ *
+ * See its pinned `parser` for the accepted syntax, including nonstandard variant forms.
+ *
+ * Examples:
+ * - `pl`: language only.
+ * - `zh-Hant-TW`: language, script and region.
+ * - `de-1901`: language and variant.
+ * - `pt_BR`: underscore separator.
+ * - `en-US-Latn`: nonstandard variant.
+ *
+ * `rslib`: https://github.com/ankitects/anki/blob/29bb700b951e3f0c0cb69b77c0180fc1fe33e6ba/rslib/i18n/src/lib.rs#L255-L306
+ * `parser`: https://github.com/zbraniecki/unic-locale/blob/4f37b35b55ab2354319abe11db6e84fe83abe895/unic-langid-impl/src/parser/mod.rs
+ */
+@JvmInline
+value class AnkiBackendLanguage private constructor(
+ val value: String,
+) {
+ companion object {
+ // Conventional Unicode syntax; rslib's parser also accepts some nonstandard variants.
+ private val LANGUAGE_IDENTIFIER =
+ Regex(
+ "(?:[a-zA-Z]{2,3}|[a-zA-Z]{5,8})" +
+ "(?:[-_][a-zA-Z]{4})?" +
+ "(?:[-_](?:[a-zA-Z]{2}|[0-9]{3}))?" +
+ "(?:[-_](?:[a-zA-Z0-9]{5,8}|[0-9][a-zA-Z0-9]{3}))*",
+ )
+
+ /**
+ * Converts an Android language tag, removing locale extensions and mapping app aliases.
+ * Existing identifiers are preserved before trying Java locale conversion. For example,
+ * rslib accepts `zh-guoyu` as Chinese, while Java would replace its language with `cmn`.
+ * Extensions, including `x-lvariant` private use subtags, are cleared before building the locale.
+ *
+ * @return null if the tag is blank or cannot be converted to a conventional Unicode identifier.
+ * Some identifiers accepted by rslib, such as `pl-PL-abcd`, fail Java conversion. Callers should
+ * pass the original tag to rslib on failure, preserving its parsing and language fallback.
+ */
+ fun fromLanguageTag(languageTag: String): AnkiBackendLanguage? {
+ if (languageTag.isBlank()) return null
+ fromIdentifier(languageTag)?.let { return it }
+ val language =
+ try {
+ Locale
+ .Builder()
+ .setLanguageTag(languageTag.replace('_', '-'))
+ .clearExtensions() // Locale.stripExtensions() requires API 26.
+ .build()
+ .toLanguageTag()
+ } catch (_: IllformedLocaleException) {
+ return null
+ }
+ return fromIdentifier(language)
+ }
+
+ private fun fromIdentifier(language: String): AnkiBackendLanguage? {
+ val backendLanguage =
+ when (language) {
+ "heb" -> "he"
+ "ind" -> "id"
+ "tgl" -> "tl"
+ "hi" -> "hi-IN"
+ else -> language
+ }
+ return backendLanguage.takeIf { LANGUAGE_IDENTIFIER.matches(it) }?.let { AnkiBackendLanguage(it) }
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt
index 75d9e53cf745..385032dfec40 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt
@@ -17,6 +17,7 @@ import com.ichi2.anki.databinding.ItemBrowserColumnsEntryBinding
import com.ichi2.anki.databinding.ItemBrowserColumnsHeadingBinding
import com.ichi2.anki.utils.ext.runWhenNotComputingLayout
import com.ichi2.anki.utils.ext.swapPositions
+import com.ichi2.anki.common.android.R as CommonR
class BrowserColumnSelectionAdapter(
val items: MutableList,
@@ -124,7 +125,7 @@ class BrowserColumnSelectionAdapter(
binding.buttonToggleColumn.apply {
// NICE_TO_HAVE: animate between + and -
val isExclude = absoluteAdapterPosition < positionOfAvailableHeading
- setImageResource(if (isExclude) R.drawable.ic_remove else R.drawable.ic_add)
+ setImageResource(if (isExclude) R.drawable.ic_remove else CommonR.drawable.ic_add)
val label = context.getString(if (isExclude) CommonString.exclude_column else CommonString.include_column)
contentDescription = label
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/browser/BrowserOptionsRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt
index e90a59c6470a..a756100208d2 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt
@@ -5,9 +5,9 @@ package com.ichi2.anki.browser
import android.content.SharedPreferences
import androidx.core.content.edit
import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.common.utils.ext.ignoreAccentsInSearch
import com.ichi2.anki.model.CardsOrNotes
import com.ichi2.anki.utils.ext.defaultBrowserSearch
-import com.ichi2.anki.utils.ext.ignoreAccentsInSearch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt
index e016c8cd2a7c..dc837e4f137e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt
@@ -1703,7 +1703,7 @@ class CardBrowserFragment :
val (type, selectedIds) = activityViewModel.querySelectionExportData() ?: return
ExportDialogFragment
.newInstance(
- requireContext().externalCacheDir ?: requireContext().cacheDir,
+ IdsFile.getDirectory(requireContext()),
type,
selectedIds,
).show(parentFragmentManager, "exportDialog")
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragmentViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragmentViewModel.kt
index b7ab64af6573..99355a584f98 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragmentViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragmentViewModel.kt
@@ -4,14 +4,12 @@ package com.ichi2.anki.browser
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.ichi2.anki.common.annotations.NeedsTest
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
class CardBrowserFragmentViewModel : ViewModel() {
val flowOfSearchForDecks = MutableSharedFlow()
- @NeedsTest("default usage")
fun openDeckSelectionDialog() =
viewModelScope.launch {
flowOfSearchForDecks.emit(Unit)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
index 5932a16432d3..98a3e0706b0f 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
@@ -6,12 +6,8 @@ import android.os.Bundle
import android.os.Parcelable
import androidx.annotation.CheckResult
import androidx.core.os.BundleCompat
-import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
import anki.collection.OpChanges
import anki.collection.OpChangesWithCount
import anki.search.BrowserColumns
@@ -65,8 +61,10 @@ import com.ichi2.anki.preferences.SharedPreferencesProvider
import com.ichi2.anki.progress.HasProgress
import com.ichi2.anki.progress.ProgressManager
import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.utils.ViewModelSavedStateHandle
import com.ichi2.anki.utils.ext.getCardOrNull
import com.ichi2.anki.utils.ext.setUserFlagForCards
+import com.ichi2.anki.utils.savedStateViewModelFactory
import com.ichi2.utils.TagsUtil.getUpdatedTags
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
@@ -112,10 +110,11 @@ typealias ReverseDirection = Boolean
* ViewModel for [com.ichi2.anki.CardBrowser]
*
* @param lastDeckIdRepository returns the last selected ID. See [LastDeckIdRepository]
- * @param cacheDir Temporary location to store data too large to pass via intent
+ * @param idsFileDirectory Location to store data too large to pass via intent. See [IdsFile.getDirectory]
* @param options Options passed to CardBrowser on startup
* @param preferences Accessor for `SharedPreferences`
* @param isFragmented `true` if a NoteEditor side panel is displayed (x-large displays)
+ * @param initialDeckId Deck requested by the caller, applied only on the first launch
* @param manualInit test-only: defer `initCompleted` until `manualInit()` is called
*/
@NeedsTest("columIndex1/2 config is not not updated on init")
@@ -123,11 +122,12 @@ typealias ReverseDirection = Boolean
@NeedsTest("search is called after launch()")
class CardBrowserViewModel(
private val lastDeckIdRepository: LastDeckIdRepository,
- private val cacheDir: File,
+ private val idsFileDirectory: File,
options: CardBrowserLaunchOptions?,
preferences: SharedPreferencesProvider,
val isFragmented: Boolean,
- val savedStateHandle: SavedStateHandle,
+ private val initialDeckId: DeckId?,
+ val savedStateHandle: ViewModelSavedStateHandle,
private val browserOptionsRepository: BrowserOptionsRepository =
BrowserOptionsRepository(preferences.sharedPrefs()),
private val manualInit: Boolean = false,
@@ -467,7 +467,7 @@ class CardBrowserViewModel(
suspend fun consumeIntentDeck(): SelectableDeck.Deck? {
if (savedStateHandle.get(STATE_LAUNCH_INTENT_CONSUMED) == true) return null
savedStateHandle[STATE_LAUNCH_INTENT_CONSUMED] = true
- val deckId = savedStateHandle.get(EXTRA_DECK_ID) ?: return null
+ val deckId = initialDeckId ?: return null
val name = withCol { decks.nameIfExists(deckId) } ?: return null
return SelectableDeck.Deck(deckId = deckId, name = name)
}
@@ -643,7 +643,7 @@ class CardBrowserViewModel(
if (selection.isEmpty()) {
null
} else {
- IdsFile(cacheDir, selection.map { it.cardOrNoteId }, "multiselect-values")
+ IdsFile(idsFileDirectory, selection.map { it.cardOrNoteId }, IdsFile.Purpose.SELECTION)
}
multiselectStateFile?.removeSafely("CardBrowserViewModel")
multiselectStateFile = idsFile
@@ -1363,11 +1363,11 @@ class CardBrowserViewModel(
suspend fun queryPreviewIntentData(): PreviewerDestination {
// If in NOTES mode, we show one Card per Note, as this matches Anki Desktop
return if (selectedRowCount() > 1) {
- PreviewerDestination(currentIndex = 0, IdsFile(cacheDir, queryAllSelectedCardIds()))
+ PreviewerDestination(currentIndex = 0, IdsFile(idsFileDirectory, queryAllSelectedCardIds(), IdsFile.Purpose.PREVIEW))
} else {
// Preview all cards, starting from the one that is currently selected
val startIndex = indexOfFirstCheckedCard() ?: 0
- PreviewerDestination(startIndex, IdsFile(cacheDir, queryOneCardIdPerNote()))
+ PreviewerDestination(startIndex, IdsFile(idsFileDirectory, queryOneCardIdPerNote(), IdsFile.Purpose.PREVIEW))
}
}
@@ -1653,21 +1653,21 @@ class CardBrowserViewModel(
fun factory(
lastDeckIdRepository: LastDeckIdRepository,
- cacheDir: File,
+ idsFileDirectory: File,
isFragmented: Boolean,
preferencesProvider: SharedPreferencesProvider? = null,
options: CardBrowserLaunchOptions?,
- ) = viewModelFactory {
- initializer {
- CardBrowserViewModel(
- lastDeckIdRepository,
- cacheDir,
- options,
- preferencesProvider ?: AnkiDroidApp.sharedPreferencesProvider,
- isFragmented,
- createSavedStateHandle(),
- )
- }
+ initialDeckId: DeckId?,
+ ) = savedStateViewModelFactory { handle ->
+ CardBrowserViewModel(
+ lastDeckIdRepository,
+ idsFileDirectory,
+ options,
+ preferencesProvider ?: AnkiDroidApp.sharedPreferencesProvider,
+ isFragmented,
+ initialDeckId,
+ handle,
+ )
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt
index 14c6d7a7d5c1..4cf9d5c73744 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt
@@ -190,7 +190,7 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() {
context: Context,
noteIds: List,
): FindAndReplaceDialogFragment {
- val file = IdsFile(context.cacheDir, noteIds, "find-replace")
+ val file = IdsFile(IdsFile.getDirectory(context), noteIds, IdsFile.Purpose.FIND_AND_REPLACE)
return FindAndReplaceDialogFragment().apply {
arguments =
Bundle().apply {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt
index 766d3510560a..dfbaffd6afba 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt
@@ -2,6 +2,7 @@
package com.ichi2.anki.browser
+import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import timber.log.Timber
@@ -27,10 +28,10 @@ class IdsFile(
) : File(path),
Parcelable {
/**
- * @param directory parent directory of the file. Generally it should be the cache directory
+ * @param directory parent directory of the file. Generally it should be [getDirectory]
* @param ids ids to store
*/
- constructor(directory: File, ids: List, prefix: String = "ids") : this(path = createTempFile(prefix, ".tmp", directory).path) {
+ constructor(directory: File, ids: List, purpose: Purpose) : this(path = createTempFile(purpose.prefix, ".tmp", directory).path) {
DataOutputStream(FileOutputStream(this)).use { outputStream ->
outputStream.writeInt(ids.size)
for (id in ids) {
@@ -65,7 +66,21 @@ class IdsFile(
dest.writeString(path)
}
+ enum class Purpose(
+ /** Uniquely identifies snapshots by filename: `multiselect-values123456789.tmp` */
+ val prefix: String,
+ ) {
+ SELECTION(prefix = "multiselect-values"),
+ FIND_AND_REPLACE(prefix = "find-replace"),
+ TAGS(prefix = "ids"),
+ PREVIEW(prefix = "ids"),
+ EXPORT(prefix = "export"),
+ SET_DUE_DATE(prefix = "set-due-date"),
+ }
+
companion object {
+ fun getDirectory(context: Context): File = context.cacheDir
+
@JvmField
@Suppress("unused")
val CREATOR =
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardBrowserSearchViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardBrowserSearchViewModel.kt
index 29ccb4faa0e4..1e228da7f650 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardBrowserSearchViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardBrowserSearchViewModel.kt
@@ -4,7 +4,6 @@ package com.ichi2.anki.browser.search
import androidx.annotation.CheckResult
import androidx.annotation.VisibleForTesting
-import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ichi2.anki.CollectionManager.withCol
@@ -14,6 +13,7 @@ import com.ichi2.anki.browser.SearchHistory.SearchHistoryEntry
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.libanki.DeckNameId
import com.ichi2.anki.libanki.NoteTypeNameID
+import com.ichi2.anki.utils.ViewModelSavedStateHandle
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -44,7 +44,7 @@ import timber.log.Timber
// The sub-fragments (StandardSearchFragment etc...) need to be able to modify/close the
// EditText, but should not be coupled directly to the parent SearchView.
class CardBrowserSearchViewModel(
- private val savedStateHandle: SavedStateHandle,
+ private val savedStateHandle: ViewModelSavedStateHandle,
) : ViewModel() {
private val searchHistoryManager = SearchHistory()
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
index a68b6dbfefbb..43ea9de1b93d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
@@ -37,6 +37,7 @@ import com.ichi2.anki.model.CardsOrNotes
import com.ichi2.anki.model.SortType
import com.ichi2.anki.utils.ext.behavior
import com.ichi2.anki.utils.ext.requireParcelable
+import com.ichi2.anki.utils.isWindowCompact
import dev.androidbroadcast.vbpd.viewBinding
import timber.log.Timber
@@ -85,6 +86,9 @@ class SortOrderBottomSheetFragment(
state = BottomSheetBehavior.STATE_EXPANDED
skipCollapsed = true
isDraggable = false
+ if (resources.isWindowCompact()) {
+ maxWidth = ViewGroup.LayoutParams.MATCH_PARENT
+ }
}
binding.list.adapter =
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt
index ad3a9b2bf769..4153b8be2dd0 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt
@@ -4,8 +4,8 @@ package com.ichi2.anki.cardviewer
import android.content.Context
import androidx.annotation.CheckResult
-import anki.config.ConfigKey
import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.common.utils.ext.hideAudioPlayButtons
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.CardOrdinal
import com.ichi2.anki.libanki.Collection
@@ -120,7 +120,7 @@ class AndroidCardRenderContext(
val preferences = context.sharedPrefs()
val cardAppearance = CardAppearance.create(ReviewerCustomFonts(), preferences, context.resources.configuration.fontScale)
val cardHtmlTemplate = CardTemplate.load(context)
- val showAudioPlayButtons = !col.config.getBool(ConfigKey.Bool.HIDE_AUDIO_PLAY_BUTTONS)
+ val showAudioPlayButtons = !col.config.hideAudioPlayButtons
return AndroidCardRenderContext(
typeAnswer,
cardAppearance,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt
index 92d437231035..a02750933275 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt
@@ -39,6 +39,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import java.io.Closeable
+import kotlin.time.Duration.Companion.milliseconds
/**
* Handles the two ways an Anki card defines sound:
@@ -317,7 +318,7 @@ class CardMediaPlayer : Closeable {
private suspend fun awaitTtsPlayer(isAutomaticPlayback: Boolean): TtsPlayer? {
val player =
- withTimeoutOrNull(TTS_PLAYER_TIMEOUT_MS) {
+ withTimeoutOrNull(TTS_PLAYER_TIMEOUT) {
ttsPlayer.await()
}
if (player == null) {
@@ -340,7 +341,7 @@ class CardMediaPlayer : Closeable {
}
companion object {
- private const val TTS_PLAYER_TIMEOUT_MS = 2_500L
+ private val TTS_PLAYER_TIMEOUT = 2_500.milliseconds
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt
index bd7881aff023..074ae75fa06d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt
@@ -153,6 +153,14 @@ class TypeAnswer(
"""