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 @@ FILESIZE ONEPLUS Roborazzi + Supernote allempty apkgfileprovider asynctasks 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( """
0) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt new file mode 100644 index 000000000000..42327e1e81c5 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.cardviewer + +import android.content.Context +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputMethodManager +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.core.content.getSystemService +import com.ichi2.ui.applyNoSuggest + +/** Applies `nosuggest` to the focused HTML answer field's keyboard connection. */ +open class TypeAnswerWebView( + context: Context, +) : WebView(context) { + // All keyboard state is owned by the UI thread. + private var noSuggest = false + + private var destroyed = false + + init { + addJavascriptInterface(Keyboard(), "AnkiDroidKeyboard") + } + + private inner class Keyboard { + @JavascriptInterface + fun setNoSuggest(enabled: Boolean) { + // JavaScript interfaces run on WebView's bridge thread. + post { + if (destroyed || noSuggest == enabled) return@post + noSuggest = enabled + // Chromium may reuse an input connection between HTML text fields. + if (isAttachedToWindow) { + context.getSystemService()?.restartInput(this@TypeAnswerWebView) + } + } + } + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? = + super.onCreateInputConnection(outAttrs).applyNoSuggest(outAttrs, noSuggest) + + override fun destroy() { + destroyed = true + super.destroy() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt index a3837ae140e8..082f1f1c2826 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt @@ -118,10 +118,10 @@ enum class ViewerCommand : MappableAction { RECORD_VOICE -> listOf(keyCode(KeyEvent.KEYCODE_V, CardSide.BOTH, shift())) SAVE_VOICE -> listOf(keyCode(KeyEvent.KEYCODE_S, CardSide.BOTH, shift())) UNDO -> listOf(keyCode(KeyEvent.KEYCODE_Z, CardSide.BOTH, ctrl())) - REDO -> listOf(keyCode(KeyEvent.KEYCODE_Z, CardSide.BOTH, ModifierKeys(shift = true, ctrl = true, alt = false))) + REDO -> listOf(keyCode(KeyEvent.KEYCODE_Z, CardSide.BOTH, ModifierKeys(shift = true, ctrl = true))) PREVIOUS_CARD_INFO -> listOf( - keyCode(KeyEvent.KEYCODE_I, CardSide.BOTH, ModifierKeys(shift = false, ctrl = true, alt = true)), + keyCode(KeyEvent.KEYCODE_I, CardSide.BOTH, ModifierKeys(ctrl = true, alt = true)), ) TOGGLE_FLAG_RED -> listOf( @@ -192,7 +192,7 @@ enum class ViewerCommand : MappableAction { keycode: Int, side: CardSide, keys: ModifierKeys = ModifierKeys.none(), - ): ReviewerBinding = ReviewerBinding(keyCode(keys, keycode), side) + ): ReviewerBinding = ReviewerBinding(keyCode(keycode, keys), side) private fun unicode( c: Char, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckDeletion.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckDeletion.kt new file mode 100644 index 000000000000..0726ee84ea41 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckDeletion.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.deckpicker + +import anki.collection.OpChangesWithCount +import anki.collection.copy +import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.utils.ext.remove +import timber.log.Timber + +/** + * Deletes a deck subtree and, when necessary, selects the nearest surviving visible deck. + * + * This always changes the collection's selected deck - on phones and in split-pane mode alike, + * and the change syncs to other devices. This intentionally diverges from Anki Desktop, which + * deletes the deck and lets the backend fall back to the Default deck: AnkiDroid's deck list + * hides an empty Default, so selecting it would display a deck the user cannot see. + * The selection change is merged into the deletion's undo entry, so a single Undo restores + * both the deck and the selection. + * + * @param deckId the deck to remove, including its child decks + */ +context(col: Collection) +internal fun removeDeckAndSelectAdjacent( + deckId: DeckId, + visibleDeckIds: List, + filter: DeckFilters, +): OpChangesWithCount { + val deletedDeckIds = col.decks.deckAndChildIds(deckId).toSet() + val selectedDeckWillBeDeleted = col.decks.selected() in deletedDeckIds + val removal = col.decks.remove(deckId) + if (!selectedDeckWillBeDeleted) return removal + + val deletionUndoStep = col.undoStatus().lastStep + val replacementDeckId = + nextVisibleDeckAfterDeletion( + deletedDeckIds = deletedDeckIds, + visibleDeckIds = visibleDeckIds, + filter = filter, + ) ?: col.decks + .allNamesAndIds(skipEmptyDefault = true) + .firstOrNull() + ?.id ?: DEFAULT_DECK_ID + Timber.i("Selecting deck %s after deleting selected deck %s", replacementDeckId, deckId) + col.decks.select(replacementDeckId) + + return removal.copy { changes = col.mergeUndoEntries(deletionUndoStep) } +} + +context(col: Collection) +private fun nextVisibleDeckAfterDeletion( + deletedDeckIds: Set, + visibleDeckIds: List, + filter: DeckFilters, +): DeckId? { + val lastDeletedIndex = visibleDeckIds.indexOfLast { it in deletedDeckIds } + if (lastDeletedIndex == -1) return null + + val remainingVisibleIds = + col.sched + .deckDueTree() + .filterAndFlattenDisplay(filter, selectedDeckId = DEFAULT_DECK_ID) + .map { it.did } + .toSet() + + return visibleDeckIds.drop(lastDeletedIndex + 1).firstOrNull { it in remainingVisibleIds } + ?: visibleDeckIds.take(lastDeletedIndex).lastOrNull { it in remainingVisibleIds } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index 64cae0c09831..7275d8faf50f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -24,10 +24,10 @@ import com.ichi2.anki.StoragePermissionSet import com.ichi2.anki.common.destinations.BrowserDestination import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.common.destinations.NoteEditorDestination +import com.ichi2.anki.common.utils.ext.schedulerVersion import com.ichi2.anki.configureRenderingMode import com.ichi2.anki.launchCatchingIO import com.ichi2.anki.libanki.CardId -import com.ichi2.anki.libanki.Consts import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Decks @@ -38,6 +38,8 @@ import com.ichi2.anki.libanki.utils.extend import com.ichi2.anki.notetype.ManageNoteTypesDestination import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.performBackupInBackground +import com.ichi2.anki.progress.HasProgress +import com.ichi2.anki.progress.ProgressManager import com.ichi2.anki.reviewreminders.ScheduleRemindersDestination import com.ichi2.anki.settings.Prefs import com.ichi2.anki.sync.syncStatus @@ -64,7 +66,10 @@ import com.ichi2.anki.common.destinations.Destination as NavigateDestination */ class DeckPickerViewModel : ViewModel(), - OnErrorListener { + OnErrorListener, + HasProgress { + override val progressManager = ProgressManager() + val flowOfStartupResponse = MutableStateFlow(null) private val flowOfDeckDueTree = MutableStateFlow(null) @@ -77,7 +82,13 @@ class DeckPickerViewModel : } /** User filter of the deck list. Shown as a search in the UI */ - private val flowOfCurrentDeckFilter = MutableStateFlow(DeckFilters.create("")) + private val flowOfDeckSearchQuery = MutableStateFlow("") + + val deckSearchQuery: String + get() = flowOfDeckSearchQuery.value + + var isDeckSearchExpanded: Boolean = false + private set /** * Keep track of which deck was last given focus in the deck list. If we find that this value @@ -100,10 +111,10 @@ class DeckPickerViewModel : val flowOfDeckList = combine( flowOfDeckDueTree, - flowOfCurrentDeckFilter, + flowOfDeckSearchQuery, flowOfFocusedDeck, flowOfRefreshDeckList.onStart { emit(Unit) }, - ) { tree, filter, _, _ -> + ) { tree, query, _, _ -> if (tree == null) return@combine FlattenedDeckList.empty // TODO: use flowOfFocusedDeck once it's set on all instances @@ -111,7 +122,7 @@ class DeckPickerViewModel : Timber.i("currentDeckId: %d", currentDeckId) FlattenedDeckList( - data = tree.filterAndFlattenDisplay(filter, currentDeckId), + data = tree.filterAndFlattenDisplay(DeckFilters.create(query), currentDeckId), hasSubDecks = tree.children.any { it.children.any() }, ) }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = FlattenedDeckList.empty) @@ -122,6 +133,8 @@ class DeckPickerViewModel : */ val deckDeletedNotification = MutableSharedFlow(extraBufferCapacity = 1) val flowOfDeleteDeckConfirmation = MutableSharedFlow(extraBufferCapacity = 1) + val flowOfRenameDeck = MutableSharedFlow(extraBufferCapacity = 1) + val flowOfStudyDeck = MutableSharedFlow(extraBufferCapacity = 1) val emptyCardsNotification = MutableSharedFlow(extraBufferCapacity = 1) val flowOfDestination = MutableSharedFlow(extraBufferCapacity = 1) val flowOfNavigate = MutableSharedFlow(extraBufferCapacity = 1) @@ -214,37 +227,54 @@ class DeckPickerViewModel : flowOfDeleteDeckConfirmation.emit(request) } + fun requestRenameSelectedDeck() = + launchCatchingIO { + val deckId = withCol { decks.selected() } + flowOfRenameDeck.emit(deckId) + } + + fun requestStudySelectedDeck() = + launchCatchingIO { + val deckId = withCol { decks.selected() } + flowOfStudyDeck.emit(deckId) + } + /** * Deletes the provided deck, child decks. and all cards inside. * - * This is a slow operation and should be inside `withProgress` - * * @param did ID of the deck to delete */ - @CheckResult // This is a slow operation and should be inside `withProgress` + @CheckResult fun deleteDeck(did: DeckId) = viewModelScope.launch { - val deckName = withCol { decks.getLegacy(did)!!.name } - val changes = undoableOp { decks.remove(listOf(did)) } - // After deletion: decks.current() reverts to Default, necessitating `focusedDeck` - // to match and avoid unnecessary scrolls in `renderPage()`. - focusedDeck = Consts.DEFAULT_DECK_ID - - deckDeletedNotification.emit( - DeckDeletionResult(deckName = deckName, cardsDeleted = changes.count), - ) + progressManager.withProgress(message = DeckPickerProgress.DELETING_DECK) { + val deckName = withCol { decks.getLegacy(did)!!.name } + val changes = + undoableOp { + removeDeckAndSelectAdjacent( + deckId = did, + visibleDeckIds = flowOfDeckList.value.data.map { it.did }, + filter = DeckFilters.create(flowOfDeckSearchQuery.value), + ) + } + focusedDeck = withCol { decks.selected() } + + deckDeletedNotification.emit( + DeckDeletionResult(deckName = deckName, cardsDeleted = changes.count), + ) + } } /** * Deletes the currently selected deck - * - * This is a slow operation and should be inside `withProgress` */ @CheckResult fun deleteSelectedDeck() = viewModelScope.launch { - val targetDeckId = withCol { decks.selected() } - deleteDeck(targetDeckId).join() + progressManager.withProgress(message = DeckPickerProgress.DELETING_DECK) { + val targetDeckId = withCol { decks.selected() } + deleteDeck(targetDeckId).join() + } } /** @@ -258,28 +288,31 @@ class DeckPickerViewModel : report: EmptyCardsReport, preserveNotes: Boolean, ) = viewModelScope.launch { - // https://github.com/ankitects/anki/blob/39e293b27d36318e00131fd10144755eec8d1922/qt/aqt/emptycards.py#L98-L109 - val toDelete = mutableListOf() - - for (note in report.notesList) { - if (preserveNotes && note.willDeleteNote) { - // leave first card - toDelete.extend(note.cardIdsList.drop(1)) - } else { - toDelete.extend(note.cardIdsList) + progressManager.withProgress(message = DeckPickerProgress.DELETING_EMPTY_CARDS) { + // https://github.com/ankitects/anki/blob/39e293b27d36318e00131fd10144755eec8d1922/qt/aqt/emptycards.py#L98-L109 + val toDelete = mutableListOf() + + for (note in report.notesList) { + if (preserveNotes && note.willDeleteNote) { + // leave first card + toDelete.extend(note.cardIdsList.drop(1)) + } else { + toDelete.extend(note.cardIdsList) + } } + val result = undoableOp { removeCardsAndOrphanedNotes(toDelete) } + emptyCardsNotification.emit(EmptyCardsResult(cardsDeleted = result.count)) } - val result = undoableOp { removeCardsAndOrphanedNotes(toDelete) } - emptyCardsNotification.emit(EmptyCardsResult(cardsDeleted = result.count)) } - // TODO: move withProgress to the ViewModel, so we don't return 'Job' fun emptyFilteredDeck(deckId: DeckId): Job = viewModelScope.launch { - Timber.i("empty filtered deck %s", deckId) - withCol { decks.select(deckId) } - undoableOp { sched.emptyFilteredDeck(decks.selected()) } - flowOfDeckCountsChanged.emit(Unit) + progressManager.withProgress { + Timber.i("empty filtered deck %s", deckId) + withCol { decks.select(deckId) } + undoableOp { sched.emptyFilteredDeck(decks.selected()) } + flowOfDeckCountsChanged.emit(Unit) + } } /** @@ -288,12 +321,14 @@ class DeckPickerViewModel : @CheckResult fun rebuildFilteredDeck(deckId: DeckId): Job = viewModelScope.launch { - Timber.i("rebuilding filtered deck %s", deckId) - withCol { - decks.select(deckId) - sched.rebuildFilteredDeck(decks.selected()) + progressManager.withProgress(message = DeckPickerProgress.REBUILDING_FILTERED_DECK) { + Timber.i("rebuilding filtered deck %s", deckId) + withCol { + decks.select(deckId) + sched.rebuildFilteredDeck(decks.selected()) + } + flowOfDeckCountsChanged.emit(Unit) } - flowOfDeckCountsChanged.emit(Unit) } /** @@ -378,6 +413,11 @@ class DeckPickerViewModel : flowOfDestination.emit(ScheduleRemindersDestination(deckId)) } + suspend fun createBackup() = + progressManager.withProgress(message = DeckPickerProgress.CREATING_BACKUP) { + performBackupInBackground(force = true) + } + /** * Launch an asynchronous task to rebuild the deck list and recalculate the deck counts. Use this * after any change to a deck (e.g., rename, importing, add/delete) that needs to be reflected @@ -387,21 +427,33 @@ class DeckPickerViewModel : */ // TODO: #17551 use tryWithCol, refresh via `ChangeManager` and collapse into one collection read. @RustCleanup("backup with 5 minute timer, instead of deck list refresh") - fun updateDeckList(): Job = - viewModelScope.launch(Dispatchers.IO) { - // WARN: On a regular sync, this blocks until the sync completes - // On a full sync, the collection is closed - if (!CollectionManager.isOpenUnsafe()) { - return@launch - } - if (Build.FINGERPRINT != "robolectric") { - // uses user's desktop settings to determine whether a backup - // actually happens - launchCatchingIO { performBackupInBackground() } - } - Timber.d("updateDeckList") - reloadDeckCounts().join() + fun updateDeckList(): Job { + if (startupJob?.isActive == true) return Job().apply { complete() } + if (startupServesFirstRefresh) { + startupServesFirstRefresh = false + return Job().apply { complete() } } + return viewModelScope.launch(Dispatchers.IO) { loadDeckList() } + } + + fun onScreenResumed() { + startupServesFirstRefresh = false + } + + private suspend fun loadDeckList() { + // WARN: On a regular sync, this blocks until the sync completes + // On a full sync, the collection is closed + if (!CollectionManager.isOpenUnsafe()) { + return + } + if (Build.FINGERPRINT != "robolectric") { + // uses user's desktop settings to determine whether a backup + // actually happens + launchCatchingIO { performBackupInBackground() } + } + Timber.d("updateDeckList") + reloadDeckCounts().join() + } fun reloadDeckCounts(): Job { loadDeckCounts?.cancel() @@ -424,7 +476,7 @@ class DeckPickerViewModel : * Checks the current scheduler version and prompts the upgrade dialog if using the legacy version. * Ensures the dialog is only shown once per collection load, even if [updateDeckList()] is called multiple times. */ - val currentSchedulerVersion = withCol { config.get("schedVer") as? Long ?: 1L } + val currentSchedulerVersion = withCol { config.schedulerVersion } if (currentSchedulerVersion == 1L && schedulerUpgradeDialogShownForVersion != 1L) { schedulerUpgradeDialogShownForVersion = 1L @@ -446,7 +498,11 @@ class DeckPickerViewModel : fun updateDeckFilter(filterText: String) { Timber.d("filter: %s", filterText) - flowOfCurrentDeckFilter.value = DeckFilters.create(filterText) + flowOfDeckSearchQuery.value = filterText + } + + fun updateDeckSearchExpanded(isExpanded: Boolean) { + isDeckSearchExpanded = isExpanded } fun toggleDeckExpand(deckId: DeckId) = @@ -539,6 +595,12 @@ class DeckPickerViewModel : data object Success : StartupResponse() } + /** The startup check launched by [handleStartup] */ + var startupJob: Job? = null + private set + + private var startupServesFirstRefresh = false + /** * The first call in showing dialogs for startup - error or success. * Attempts startup if storage permission has been acquired, else, it requests the permission @@ -553,17 +615,30 @@ class DeckPickerViewModel : } Timber.d("handleStartup: Continuing after permission granted") - val failure = InitialActivity.getStartupFailureType(environment.preferences, environment::initializeAnkiDroidFolder) - if (failure != null) { - flowOfStartupResponse.value = StartupResponse.FatalError(failure) - return - } + startupServesFirstRefresh = true + startupJob = + viewModelScope.launch { + // opening the collection waits on the collection queue, which a sync stuck on an + // unresponsive server can hold for a long time. Waiting on the main thread here + // froze the DeckPicker when it was recreated + val failure = + withContext(Dispatchers.IO) { + // Once we have permissions, we know the `StorageDecision` + environment.decideStorageIfUndecided() + InitialActivity.getStartupFailureType(environment.preferences, environment::initializeAnkiDroidFolder) + } + if (failure != null) { + flowOfStartupResponse.value = StartupResponse.FatalError(failure) + return@launch + } - // successful startup + // successful startup - configureRenderingMode() + configureRenderingMode() + viewModelScope.launch(Dispatchers.IO) { loadDeckList() } - flowOfStartupResponse.value = StartupResponse.Success + flowOfStartupResponse.value = StartupResponse.Success + } } interface AnkiDroidEnvironment { @@ -574,6 +649,16 @@ class DeckPickerViewModel : /** The preferences of the (profile) context the collection path is read from */ val preferences: SharedPreferences + /** + * Persists the collection path if the user has not decided yet; no-op otherwise. + * + * Called once [hasRequiredPermissions] passes, so a deferred decision is recorded + * before the collection is opened. + * + * @see InitialActivity.decideStorageIfUndecided + */ + fun decideStorageIfUndecided() + fun initializeAnkiDroidFolder(): Boolean } @@ -632,11 +717,11 @@ class DeckPickerViewModel : val undoAvailable = undoAvailable() // besides checking for cards being available also consider if we have empty decks val isColEmpty = isEmpty && decks.count() == 1 + val showDeckActions = !isColEmpty // the correct sync status is fetched in the next call so "Normal" is used as a placeholder - OptionsMenuState(searchIcon, undoLabel, SyncIconState.Normal, undoAvailable, isColEmpty) - }?.let { (searchIcon, undoLabel, _, undoAvailable, isColEmpty) -> - val syncIcon = fetchSyncIconState() - OptionsMenuState(searchIcon, undoLabel, syncIcon, undoAvailable, isColEmpty) + OptionsMenuState(searchIcon, undoLabel, SyncIconState.Normal, undoAvailable, showDeckActions) + }?.let { state -> + state.copy(syncIcon = fetchSyncIconState()) } } @@ -694,6 +779,14 @@ class DeckPickerViewModel : } } +/** The operation [DeckPickerViewModel] is running, shown as a progress message by the UI. */ +enum class DeckPickerProgress { + DELETING_DECK, + REBUILDING_FILTERED_DECK, + DELETING_EMPTY_CARDS, + CREATING_BACKUP, +} + /** Result of [DeckPickerViewModel.deleteDeck] */ data class DeckDeletionResult( val deckName: String, @@ -748,5 +841,5 @@ data class OptionsMenuState( val undoLabel: String?, val syncIcon: SyncIconState, val undoAvailable: Boolean, - val isColEmpty: Boolean, + val showDeckActions: Boolean, ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/AsyncDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/AsyncDialogFragment.kt index 48ad2666d33e..2137c3d4a5c4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/AsyncDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/AsyncDialogFragment.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2015 Timothy Rae - * - * 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) 2015 Timothy Rae + package com.ichi2.anki.dialogs import android.content.Context diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt index f039a9c6866d..500a5921957e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt @@ -9,6 +9,8 @@ import android.graphics.Color import android.os.Build import android.os.Bundle import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -26,8 +28,10 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams +import androidx.core.widget.TextViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -45,10 +49,12 @@ import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment import com.ichi2.anki.common.annotations.NeedsTest +import com.ichi2.anki.common.utils.android.getResFromAttr +import com.ichi2.anki.compat.setTooltipTextCompat import com.ichi2.anki.databinding.DialogChangeNoteTypeBinding import com.ichi2.anki.databinding.DialogFieldsBinding import com.ichi2.anki.databinding.DialogTemplatesBinding -import com.ichi2.anki.databinding.ViewTabLayoutIconOnEndBinding +import com.ichi2.anki.databinding.ViewTabLayoutIconOnStartBinding import com.ichi2.anki.dialogs.ConversionType.CLOZE_TO_CLOZE import com.ichi2.anki.dialogs.ConversionType.CLOZE_TO_REGULAR import com.ichi2.anki.dialogs.ConversionType.REGULAR_TO_CLOZE @@ -178,6 +184,7 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note private fun setupNoteTypeSpinner(binding: DialogChangeNoteTypeBinding) { binding.CardEditorModelText.text = BidiFormatter.getInstance().unicodeWrap(viewModel.inputNoteType.name) + binding.CardEditorModelText.setTooltipTextCompat(viewModel.inputNoteType.name) binding.destNoteTypeSpinner.apply { adapter = createNoteTypeAdapter() @@ -225,7 +232,10 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note text = noteType.name setTextColor(if (noteType.isCloze) clozeColor else defaultViewTextColor) isSingleLine = false - ellipsize = null + // Leave room for the mappings even when the selected note type has a long name. + maxLines = resources.getInteger(R.integer.change_note_type_name_max_lines) + ellipsize = TextUtils.TruncateAt.END + setTooltipTextCompat(noteType.name) updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT } @@ -264,6 +274,25 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val tabLayout = binding.changeNoteTypeTabLayout createTabMediator(tabLayout, viewPager).attach() + // Measure the labels and icons before fixed tabs constrain their width. + val minimumTabWidth = + (0 until tabLayout.tabCount).maxOf { position -> + val tab = requireNotNull(tabLayout.getTabAt(position)) + val content = requireNotNull(tab.customView) + content.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + content.measuredWidth + tab.view.paddingLeft + tab.view.paddingRight + } + tabLayout.addOnLayoutChangeListener { _, left, _, right, _, _, _, _, _ -> + val availableWidth = right - left - tabLayout.paddingLeft - tabLayout.paddingRight + val mode = + if (minimumTabWidth * tabLayout.tabCount <= availableWidth) TabLayout.MODE_FIXED else TabLayout.MODE_AUTO + if (tabLayout.tabMode != mode) { + tabLayout.tabMode = mode + tabLayout.doOnNextLayout { + tabLayout.setScrollPosition(tabLayout.selectedTabPosition, 0f, true) + } + } + } // Explicitly set initial tab in ViewModel to match UI viewPager.setCurrentItem(0, false) @@ -275,7 +304,7 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note viewPager: ViewPager2, ): TabLayoutMediator = TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int -> - val binding = ViewTabLayoutIconOnEndBinding.inflate(LayoutInflater.from(tabLayout.context), tabLayout, false) + val binding = ViewTabLayoutIconOnStartBinding.inflate(LayoutInflater.from(tabLayout.context), tabLayout, false) when (position) { 0 -> { binding.tabIcon.setImageResource(R.drawable.ic_mode_edit_white) @@ -423,16 +452,6 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val inputFieldNames = viewModel.inputNoteType.fieldsNames val outputFieldNames = viewModel.outputNoteType.fieldsNames - fun buildFieldLayout() = - LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = - LinearLayout.LayoutParams( - MATCH_PARENT, - WRAP_CONTENT, - ) - } - fun buildFieldSpinner(spinnerIndex: Int) = Spinner(requireContext()) .apply { @@ -475,20 +494,8 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note } } - fun buildFieldText(initialText: String) = - MaterialTextView(requireContext()).apply { - layoutParams = - LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) - text = initialText - textAlignment = View.TEXT_ALIGNMENT_CENTER - } - for (i in outputFieldNames.indices) { - val fieldLayout = - buildFieldLayout().apply { - addView(buildFieldSpinner(i)) - addView(buildFieldText(outputFieldNames[i])) - } - binding.fieldsContainer.addView(fieldLayout) + for ((i, name) in outputFieldNames.withIndex()) { + binding.fieldsContainer.addView(createMappingRow(buildFieldSpinner(i), name)) } } } @@ -623,16 +630,6 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val inputTemplateNames = viewModel.inputNoteType.templatesNames val outputTemplateNames = viewModel.outputNoteType.templatesNames - fun buildTemplateLayout() = - LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = - LinearLayout.LayoutParams( - MATCH_PARENT, - WRAP_CONTENT, - ) - } - fun buildTemplateSpinner(spinnerIndex: Int) = Spinner(requireContext()) .apply { @@ -668,30 +665,35 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note } } - fun buildTemplateText(templateName: String) = - MaterialTextView(requireContext()).apply { - layoutParams = - LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) - text = templateName - textAlignment = View.TEXT_ALIGNMENT_CENTER - } - for ((i, name) in outputTemplateNames.withIndex()) { - val templateLayout = - buildTemplateLayout().apply { - addView( - buildTemplateSpinner( - spinnerIndex = i, - ), - ) - addView(buildTemplateText(templateName = name)) - } - binding.templatesContainer.addView(templateLayout) + binding.templatesContainer.addView(createMappingRow(buildTemplateSpinner(i), name)) } } } } +private fun createMappingRow( + spinner: Spinner, + label: String, +) = LinearLayout(spinner.context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + // Baseline alignment can push wrapped labels below the row's measured height. + isBaselineAligned = false + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + addView(spinner) + addView( + MaterialTextView(context).apply { + layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) + val themeTextColors = textColors + TextViewCompat.setTextAppearance(this, getResFromAttr(context, com.google.android.material.R.attr.textAppearanceBodyLarge)) + setTextColor(themeTextColors) + text = label + textAlignment = View.TEXT_ALIGNMENT_CENTER + }, + ) +} + /** * Changes note type of multiple notes, displaying a message on success */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CreateDeckDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CreateDeckDialog.kt index c230871175b8..bbb73b98f2d4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CreateDeckDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CreateDeckDialog.kt @@ -162,7 +162,13 @@ class CreateDeckDialog( deckName: String?, ) { val deckNameWithParentName = getColUnsafe.decks.getSubdeckName(did, deckName) - createDeck(deckNameWithParentName!!) + if (deckNameWithParentName == null) { + Timber.d("CreateDeckDialog::createSubDeck - Not creating invalid subdeck name '%s'", deckName) + displayFeedback(context.getString(CommonString.invalid_deck_name), Snackbar.LENGTH_LONG) + shownDialog?.dismiss() + return + } + createDeck(deckNameWithParentName) } fun createDeck(deckName: String) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt index ce92f5375bfc..bf6c5df3ecac 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt @@ -29,6 +29,8 @@ import com.ichi2.anki.R import com.ichi2.anki.ankiActivity import com.ichi2.anki.backend.DatabaseCorruption import com.ichi2.anki.backend.getDatabaseVersion +import com.ichi2.anki.common.destinations.PreferencesDestination +import com.ichi2.anki.common.destinations.navigate import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType.DIALOG_CONFIRM_DATABASE_CHECK @@ -102,11 +104,11 @@ class DatabaseErrorDialog : AsyncDialogFragment() { } return when (requireDialogType()) { DIALOG_LOAD_FAILED -> { + isCancelable = false // Collection failed to load; give user the option of either choosing from repair options, or closing // the activity alertDialog.show { title(CommonString.open_collection_failed_title) - cancelable(false) message(text = message) setIcon(R.drawable.ic_warning) positiveButton(CommonString.error_handling_options) { @@ -118,11 +120,11 @@ class DatabaseErrorDialog : AsyncDialogFragment() { } } DIALOG_DB_ERROR -> { + isCancelable = false // Database Check failed to execute successfully; give user the option of either choosing from repair // options, submitting an error report, or closing the activity alertDialog .create { - cancelable(false) title(CommonString.answering_error_title) message(text = message) setIcon(R.drawable.ic_warning) @@ -355,17 +357,21 @@ class DatabaseErrorDialog : AsyncDialogFragment() { } } DIALOG_DB_LOCKED -> { - // If the database is locked, all we can do is ask the user to exit. + isCancelable = false alertDialog.show { title(CommonString.database_locked_title) message(text = message) positiveButton(CommonString.close) { closeCollectionAndFinish() } - cancelable(false) + // the user can change the 'AnkiDroid directory' to resolve the conflict + neutralButton(CommonString.settings) { + navigate(PreferencesDestination.Advanced) + } } } INCOMPATIBLE_DB_VERSION -> { + isCancelable = false val values: MutableList = ArrayList(2) val options = mutableListOf() options.add(makeBold(res.getString(CommonString.backup_restore))) @@ -375,7 +381,6 @@ class DatabaseErrorDialog : AsyncDialogFragment() { values.add(IncompatibleDbVersionEntries.ONE_WAY) } alertDialog.show { - cancelable(false) title(CommonString.incompatible_database_version_title) setIcon(R.drawable.ic_warning) positiveButton(CommonString.close) { @@ -405,6 +410,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { } } DIALOG_STORAGE_UNAVAILABLE_AFTER_UNINSTALL -> { + isCancelable = false val listItems = UninstallListItem.createList() alertDialog.show { title(CommonString.directory_inaccessible_after_uninstall) @@ -415,7 +421,6 @@ class DatabaseErrorDialog : AsyncDialogFragment() { dismiss() } } - cancelable(false) } } } @@ -585,7 +590,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { DIALOG_CONFIRM_DATABASE_CHECK -> res().getString(CommonString.check_db_warning) DIALOG_CONFIRM_RESTORE_BACKUP -> res().getString(CommonString.restore_backup) DIALOG_ONE_WAY_SYNC_FROM_SERVER -> res().getString(CommonString.backup_full_sync_from_server_question) - DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary) + DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary_new, res().getString(CommonString.col_path)) INCOMPATIBLE_DB_VERSION -> { var databaseVersion = -1 try { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerConfirmDeleteDeckDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerConfirmDeleteDeckDialog.kt index 732a40cdc630..1ddbc0e5e9aa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerConfirmDeleteDeckDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerConfirmDeleteDeckDialog.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2015 Timothy Rae - * - * 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) 2015 Timothy Rae package com.ichi2.anki.dialogs diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt index 1ebd8fb14e8b..85f24b7ec1de 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2015 Timothy Rae - * - * 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) 2015 Timothy Rae + package com.ichi2.anki.dialogs import android.app.Dialog diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerMaintenanceDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerMaintenanceDialog.kt new file mode 100644 index 000000000000..8eda12b59de3 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerMaintenanceDialog.kt @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.dialogs + +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CommonString +import com.ichi2.anki.ui.internationalization.sentenceCase +import com.ichi2.anki.utils.ext.requireString +import com.ichi2.utils.create +import com.ichi2.utils.message +import com.ichi2.utils.negativeButton +import com.ichi2.utils.positiveButton +import com.ichi2.utils.title + +/** Keeps a maintenance decision pending until the user confirms or dismisses it. */ +class DeckPickerMaintenanceDialog : DialogFragment() { + enum class Check( + val requestKey: String, + ) { + MEDIA("request_check_media"), + DATABASE("request_check_database"), + } + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { + val check = Check.valueOf(requireArguments().requireString(ARG_CHECK)) + return AlertDialog.Builder(requireContext()).create { + when (check) { + Check.MEDIA -> { + title(text = TR.sentenceCase.checkMediaTitle) + message(CommonString.check_media_warning) + } + Check.DATABASE -> { + title(text = TR.sentenceCase.checkDatabase) + message(text = requireArguments().requireString(ARG_WARNING)) + } + } + positiveButton(if (check == Check.MEDIA) CommonString.dialog_ok else CommonString.integrity_check_continue_anyway) { + parentFragmentManager.setFragmentResult(check.requestKey, Bundle()) + } + negativeButton(CommonString.dialog_cancel) + } + } + + companion object { + const val TAG = "maintenanceConfirmation" + private const val ARG_CHECK = "check" + private const val ARG_WARNING = "warning" + + fun newInstance( + check: Check, + warning: String? = null, + ) = DeckPickerMaintenanceDialog().apply { + arguments = + Bundle().apply { + putString(ARG_CHECK, check.name) + putString(ARG_WARNING, warning) + } + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerNoSpaceLeftDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerNoSpaceLeftDialog.kt index c9dfbb57f794..68f954785a95 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerNoSpaceLeftDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerNoSpaceLeftDialog.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2015 Timothy Rae - * - * 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) 2015 Timothy Rae package com.ichi2.anki.dialogs diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 302e0d45568f..750c13576612 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -76,6 +76,7 @@ class EditDeckDescriptionDialog : DialogFragment() { handleOutsideTouch(binding) { viewModel.onBackRequested() } } setCanceledOnTouchOutside(false) + // TODO: Use DialogFragment.isCancelable and retain 'Back' handling setCancelable(false) onBackPressedDispatcher.addCallback(this, onUnsavedChangesBackCallback) show() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt index 53a4e41ad03d..112c2fc3c272 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt @@ -43,7 +43,6 @@ import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.libanki.emptyCids import com.ichi2.anki.ui.internationalization.sentenceCase -import com.ichi2.anki.withProgress import com.ichi2.utils.message import com.ichi2.utils.negativeButton import com.ichi2.utils.positiveButton @@ -298,8 +297,6 @@ fun DeckPicker.startDeletingEmptyCards( keepNotes, ) launchCatchingTask { - withProgress(TR.emptyCardsDeleting()) { - viewModel.deleteEmptyCards(report, keepNotes).join() - } + viewModel.deleteEmptyCards(report, keepNotes).join() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsViewModel.kt index ab48a33c9be2..dd22ff25fd59 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsViewModel.kt @@ -10,6 +10,7 @@ import com.ichi2.anki.dialogs.EmptyCardsUiState.EmptyCardsSearchFailure import com.ichi2.anki.dialogs.EmptyCardsUiState.EmptyCardsSearchResult import com.ichi2.anki.dialogs.EmptyCardsUiState.SearchingForEmptyCards import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -19,18 +20,22 @@ class EmptyCardsViewModel : ViewModel() { val uiState: StateFlow field = MutableStateFlow(SearchingForEmptyCards) + private var searchJob: Job? = null + fun searchForEmptyCards() { - viewModelScope.launch { - runCatching { withCol { getEmptyCards() } } - .onFailure { exception -> - if (exception is CancellationException) { - throw exception + if (searchJob != null) return + searchJob = + viewModelScope.launch { + runCatching { withCol { getEmptyCards() } } + .onFailure { exception -> + if (exception is CancellationException) { + throw exception + } + uiState.emit(EmptyCardsSearchFailure(exception)) + }.onSuccess { emptyCardsReport -> + uiState.emit(EmptyCardsSearchResult(emptyCardsReport)) } - uiState.emit(EmptyCardsSearchFailure(exception)) - }.onSuccess { emptyCardsReport -> - uiState.emit(EmptyCardsSearchResult(emptyCardsReport)) - } - } + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FlagAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FlagAdapter.kt index db74b7083326..664c41276a80 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FlagAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FlagAdapter.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.dialogs diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/IntegerDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/IntegerDialog.kt index 56789b6d7a84..8e39bf13cebc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/IntegerDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/IntegerDialog.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2018 Mike Hardy - * - * 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) 2018 Mike Hardy package com.ichi2.anki.dialogs diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt index a74ad89f9677..1de223be8fcf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt @@ -67,9 +67,10 @@ class LoadingDialogFragment : DialogFragment() { * if it's already showing and the new call doesn't modify input parameters(ex. [cancellable]). In * any other cases, the old instance will be removed and a new one will be used. * - * Note: Multiple calls of this method will result in a single dialog being shown, this also - * implies that a call to [dismissLoadingDialog] will dismiss the dialogs of all calls. Callers need - * to handle this scenario by combining the loading states and manually handling showing/dismissing. + * Note: Multiple calls with the same [tag] will result in a single dialog being shown, this also + * implies that a call to [dismissLoadingDialog] with that [tag] will dismiss the dialogs of all + * those calls. Callers need to handle this scenario by combining the loading states and manually + * handling showing/dismissing, or by using different tags. * * @param message the message to show along with the [LoadingIndicator] or null to default to * use "Processing..." @@ -80,9 +81,10 @@ class LoadingDialogFragment : DialogFragment() { fun AnkiActivity.showLoadingDialog( message: String? = null, cancellable: Boolean = true, + tag: String = LoadingDialogFragment.TAG, ) { val fragment = - supportFragmentManager.findFragmentByTag(LoadingDialogFragment.TAG) as? LoadingDialogFragment + supportFragmentManager.findFragmentByTag(tag) as? LoadingDialogFragment val isAlreadyShowing = fragment?.dialog?.isShowing == true if (isAlreadyShowing) { // if a dialog is already showing and it has the same input params then just update the @@ -99,7 +101,7 @@ fun AnkiActivity.showLoadingDialog( removeImmediately(fragment) val loadingDialog = LoadingDialogFragment.newInstance(message, cancellable) // showNow() avoids a race condition - removal is synchronous - loadingDialog.showNow(supportFragmentManager, LoadingDialogFragment.TAG) + loadingDialog.showNow(supportFragmentManager, tag) } /** Synchronously removes the provided [LoadingDialogFragment] if valid(not null) */ @@ -110,10 +112,10 @@ private fun AnkiActivity.removeImmediately(fragment: LoadingDialogFragment?) { } /** - * Dismisses and removes the current displayed [LoadingDialogFragment] if one is present. + * Dismisses and removes the [LoadingDialogFragment] shown with [tag] if one is present. */ -fun AnkiActivity.dismissLoadingDialog() { +fun AnkiActivity.dismissLoadingDialog(tag: String = LoadingDialogFragment.TAG) { val fragment = - supportFragmentManager.findFragmentByTag(LoadingDialogFragment.TAG) as? LoadingDialogFragment + supportFragmentManager.findFragmentByTag(tag) as? LoadingDialogFragment removeImmediately(fragment) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt index f865edd7e019..3f9dfb1d318b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt @@ -3,7 +3,11 @@ package com.ichi2.anki.dialogs import android.app.Dialog +import android.content.Context import android.os.Bundle +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.view.ViewGroup import android.view.WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM import android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE @@ -15,13 +19,15 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.recyclerview.widget.RecyclerView +import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment +import com.ichi2.anki.common.utils.android.getColorFromAttr import com.ichi2.anki.databinding.DialogLocaleSelectionBinding import com.ichi2.anki.dialogs.LocaleSelectionDialog.LocaleListAdapter.TextViewHolder import com.ichi2.anki.servicelayer.LanguageHintService import com.ichi2.ui.AccessibleSearchView -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.TypedFilter import com.ichi2.utils.cancelable import com.ichi2.utils.customView @@ -55,7 +61,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { style: Int, ) { super.setupDialog(dialog, style) - dialog.window?.let { resizeWhenSoftInputShown(it) } + setDialogKeyboardResize(dialog) // this is required for the keyboard to appear: https://stackoverflow.com/a/10133603/ dialog.window?.clearFlags(FLAG_NOT_FOCUSABLE or FLAG_ALT_FOCUSABLE_IM) } @@ -109,7 +115,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { position: Int, ) { val locale = filteredLocales[position] - holder.textView.text = locale.displayName + holder.textView.text = locale.label(holder.textView.context) holder.textView.setOnClickListener { onLocaleSelected(locale) } } @@ -123,7 +129,11 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { ): List { val normalisedConstraint = constraint.toString().lowercase(Locale.getDefault()) return items.filter { - it.displayName.lowercase(Locale.getDefault()).contains(normalisedConstraint) + it + .label(requireContext()) + .toString() + .lowercase(Locale.getDefault()) + .contains(normalisedConstraint) } } @@ -153,3 +163,17 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { private val IPALanguage = Locale.Builder().setLanguageTag("und-fonipa").build() } } + +/** Some locales like Locale.ROOT have empty display name */ +internal fun Locale.label(context: Context): CharSequence = + displayName.ifBlank { + val text = context.getString(CommonString.locale_selection_dialog_unknown_language, toLanguageTag()) + val spannable = SpannableString(text) + spannable.setSpan( + ForegroundColorSpan(getColorFromAttr(context, android.R.attr.textColorSecondary)), + 0, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + spannable + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SyncErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SyncErrorDialog.kt index 741b8cc98193..64af9a440abc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SyncErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SyncErrorDialog.kt @@ -17,6 +17,7 @@ import com.ichi2.anki.CommonString import com.ichi2.anki.ConflictResolution import com.ichi2.anki.R import com.ichi2.anki.common.time.TimeManager +import com.ichi2.anki.dialogs.SyncErrorDialog.Type.DIALOG_COLLECTION_TOO_LARGE import com.ichi2.anki.dialogs.SyncErrorDialog.Type.DIALOG_CONNECTION_ERROR import com.ichi2.anki.dialogs.SyncErrorDialog.Type.DIALOG_MEDIA_SYNC_ERROR import com.ichi2.anki.dialogs.SyncErrorDialog.Type.DIALOG_SYNC_BASIC_CHECK_ERROR @@ -157,12 +158,12 @@ class SyncErrorDialog : AsyncDialogFragment() { .create() } DIALOG_SYNC_CORRUPT_COLLECTION -> { + isCancelable = false dialog .setPositiveButton(CommonString.dialog_ok) { _, _ -> } .setNegativeButton(CommonString.help) { _, _ -> requireContext().openUrl(R.string.repair_deck) - }.setCancelable(false) - .create() + }.create() } DIALOG_SYNC_BASIC_CHECK_ERROR -> { dialog @@ -192,6 +193,14 @@ class SyncErrorDialog : AsyncDialogFragment() { setupEnterKeyHandler() } } + DIALOG_COLLECTION_TOO_LARGE -> { + dialog + .setPositiveButton(TR.sentenceCase.checkDatabase) { _, _ -> + requireSyncErrorDialogListener().integrityCheck() + activity?.dismissAllDialogFragments() + }.setNegativeButton(CommonString.dialog_cancel) { _, _ -> } + .create() + } } } @@ -211,6 +220,7 @@ class SyncErrorDialog : AsyncDialogFragment() { DIALOG_MEDIA_SYNC_ERROR, DIALOG_SYNC_CORRUPT_COLLECTION, DIALOG_SYNC_BASIC_CHECK_ERROR, + DIALOG_COLLECTION_TOO_LARGE, -> res().getString(CommonString.sync_error) DIALOG_SYNC_CLOCK_OFF -> res().getString(CommonString.vague_error) } @@ -319,6 +329,7 @@ class SyncErrorDialog : AsyncDialogFragment() { DIALOG_SYNC_CORRUPT_COLLECTION(9), DIALOG_SYNC_BASIC_CHECK_ERROR(10), DIALOG_SYNC_CLOCK_OFF(11), + DIALOG_COLLECTION_TOO_LARGE(12), ; companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/TtsPlaybackErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/TtsPlaybackErrorDialog.kt index 00bc25dba0a8..f9038af6eecc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/TtsPlaybackErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/TtsPlaybackErrorDialog.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2024 RohanRaj123 - * - * 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 RohanRaj123 package com.ichi2.anki.dialogs diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/Utils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/Utils.kt index 6c8ada28400c..6cd46863a931 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/Utils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/Utils.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2022 Oakkitten - * - * 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 Oakkitten + package com.ichi2.anki.dialogs import android.text.method.LinkMovementMethod diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/WhiteBoardWidthDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/WhiteBoardWidthDialog.kt index 412beb1c7bf4..834607a33b3e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/WhiteBoardWidthDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/WhiteBoardWidthDialog.kt @@ -1,18 +1,5 @@ -/* - * 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.dialogs diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt index a0756be4b046..83e4d4ada92e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt @@ -15,7 +15,6 @@ import com.ichi2.anki.CommonString import com.ichi2.anki.OnContextAndLongClickListener import com.ichi2.anki.OnContextAndLongClickListener.Companion.setOnContextAndLongClickListener import com.ichi2.anki.R -import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.databinding.ItemTagBinding import com.ichi2.ui.CheckBoxTriStates import com.ichi2.ui.CheckBoxTriStates.State.CHECKED @@ -39,6 +38,20 @@ class TagsArrayAdapter( val binding: ItemTagBinding, ) : RecyclerView.ViewHolder(binding.root) { internal lateinit var node: TagTreeNode + private set + + internal fun bindNode(node: TagTreeNode) { + clearNodeBinding() + this.node = node + node.vh = this + } + + internal fun clearNodeBinding() { + // If a note already has a replacement holder, preserve its binding. + if (::node.isInitialized && node.vh === this) { + node.vh = null + } + } @get:VisibleForTesting(otherwise = VisibleForTesting.NONE) val text: String @@ -63,9 +76,6 @@ class TagsArrayAdapter( * we want to save the [isExpanded] state of child tags, even if the parent is not expanded. * When we expand a tag, we do not want to walk the tree to determine how many nodes were added or removed. * @param isExpanded whether the node is expanded or not - * @param subtreeCheckedCnt The number of checked nodes in the subtree (self included). This exists - * because we want to dynamically turn a checkbox from unchecked into indeterminate if at least one of its - * descendants is checked, or from indeterminate to unchecked if all of its descendants are unchecked. * @param vh The reference to currently bound [ViewHolder]. A node bound with some [ViewHolder] must have [vh] nonnull. * @see onBindViewHolder for the binding */ @@ -76,7 +86,6 @@ class TagsArrayAdapter( val level: Int, var subtreeSize: Int, var isExpanded: Boolean, - var subtreeCheckedCnt: Int, var vh: ViewHolder?, ) { /** @@ -120,38 +129,31 @@ class TagsArrayAdapter( /** * Set the cycle style of the node's [vh]'s checkbox. - * For nodes without checked descendants: CHECKED -> UNCHECKED -> CHECKED -> ... - * For nodes with checked descendants: CHECKED -> INDETERMINATE -> CHECKED -> ... + * For nodes without selected descendants: CHECKED -> UNCHECKED -> CHECKED -> ... + * For nodes with checked or indeterminate descendants: CHECKED -> INDETERMINATE -> CHECKED -> ... * * @param tags The [TagsList] that manages tags. */ fun updateCheckBoxCycleStyle(tags: TagsList) { - val realSubtreeCnt = subtreeCheckedCnt - if (tags.isChecked(tag)) 1 else 0 - val hasDescendantChecked = realSubtreeCnt > 0 - vh?.binding?.checkBoxView?.cycleIndeterminateToChecked = hasDescendantChecked - vh?.binding?.checkBoxView?.cycleCheckedToIndeterminate = hasDescendantChecked + val hasSelectedDescendants = tags.hasSelectedDescendants(tag) + vh?.binding?.checkBoxView?.cycleIndeterminateToChecked = hasSelectedDescendants + vh?.binding?.checkBoxView?.cycleCheckedToIndeterminate = hasSelectedDescendants } /** - * When the checkbox of the node changes, update [subtreeCheckedCnt]s of itself and its ancestors. - * If the checkbox is INDETERMINATE now, it must be CHECKED before according to the checkbox cycle style. - * @see updateCheckBoxCycleStyle - * - * Update the checkbox to INDETERMINATE if it was UNCHECK and now [subtreeCheckedCnt] > 0. - * Update the checkbox to UNCHECK if it was INDETERMINATE and now [subtreeCheckedCnt] == 0. + * Update ancestor states from the complete selection, independently of which nodes are visible. + * Preserve tags present on some notes even when their last selected descendant is cleared. * * @param tags The [TagsList] that manages tags. */ fun onCheckStateChanged(tags: TagsList) { - val delta = if (checkBoxState == CHECKED) 1 else -1 - fun update(node: TagTreeNode) { - node.subtreeCheckedCnt += delta - if (node.checkBoxState == UNCHECKED && node.subtreeCheckedCnt > 0) { + val hasSelectedDescendants = tags.hasSelectedDescendants(node.tag) + if (!tags.isChecked(node.tag) && hasSelectedDescendants) { tags.setIndeterminate(node.tag) node.checkBoxState = INDETERMINATE } - if (node.checkBoxState == INDETERMINATE && node.subtreeCheckedCnt == 0) { + if (tags.isIndeterminate(node.tag) && !tags.isPartiallySelected(node.tag) && !hasSelectedDescendants) { tags.uncheck(node.tag) node.checkBoxState = UNCHECKED } @@ -224,7 +226,7 @@ class TagsArrayAdapter( tags.sort() } - /** Refresh cached subtree counts after bulk selection changes */ + /** Refresh the tag tree and checkbox states after bulk selection changes. */ fun notifyCheckedStatusesChanged() { rebuildTagTree() notifyDataSetChanged() @@ -276,8 +278,7 @@ class TagsArrayAdapter( position: Int, ) { val binding = holder.binding - holder.node = getVisibleTagTreeNode(position)!! - holder.node.vh = holder + holder.bindNode(getVisibleTagTreeNode(position)!!) holder.itemView.tag = holder.node.tag if (hasVisibleNestedTag) { @@ -304,6 +305,11 @@ class TagsArrayAdapter( holder.node.updateCheckBoxCycleStyle(tags) } + override fun onViewRecycled(holder: ViewHolder) { + holder.clearNodeBinding() + super.onViewRecycled(holder) + } + /** * Find the [TagTreeNode] of the [index]-th visible tag. * Implemented by walking the tree using [TagTreeNode.subtreeSize]. @@ -346,7 +352,6 @@ class TagsArrayAdapter( * * @param expandTarget The target tag to expand. Do nothing if it is empty or not found. */ - @NeedsTest("#18481 - case insensitivity") private fun rebuildTagTree(expandTarget: String = "") { // init mapping for newly added tags filteredList.forEach { @@ -361,14 +366,13 @@ class TagsArrayAdapter( } hasVisibleNestedTag = false val stack = Stack() - treeRoot = TagTreeNode("", null, ArrayList(), -1, 0, true, 0, null) + treeRoot = TagTreeNode("", null, ArrayList(), -1, 0, true, null) stack.add(treeRoot) tagToNode.clear() fun stackPopAndPushUp() { val popped = stack.pop() stack.peek().subtreeSize += popped.getContributeSize() - stack.peek().subtreeCheckedCnt += popped.subtreeCheckedCnt } for (tag in filteredList) { // root will never be popped @@ -381,7 +385,7 @@ class TagsArrayAdapter( } val parent = stack.peek() val node = - TagTreeNode(tag, parent, ArrayList(), parent.level + 1, 1, tagToIsExpanded[tag]!!, if (tags.isChecked(tag)) 1 else 0, null) + TagTreeNode(tag, parent, ArrayList(), parent.level + 1, 1, tagToIsExpanded[tag]!!, null) parent.children.add(node) tagToNode[tag] = node stack.add(node) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt index 0a63dc85b07e..9285a7a87666 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt @@ -13,7 +13,6 @@ import android.text.InputType import android.text.Spanned import android.view.MenuItem import android.view.View -import android.view.Window import android.view.WindowManager import android.widget.EditText import android.widget.RadioGroup @@ -48,7 +47,7 @@ import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.ui.AccessibleSearchView -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.TagsUtil import com.ichi2.utils.customView import com.ichi2.utils.getInputField @@ -67,6 +66,8 @@ import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import timber.log.Timber import java.io.IOException +import kotlin.time.Duration.Companion.milliseconds +import com.ichi2.anki.common.android.R as CommonR class TagsDialog : AnalyticsDialogFragment { /** @@ -150,7 +151,7 @@ class TagsDialog : AnalyticsDialogFragment { checkedTags: ArrayList = arrayListOf(), ): TagsDialog { // TODO: checkedTags is unbounded and could exceed the bundle size - val file = IdsFile(context.cacheDir, noteIds) + val file = IdsFile(IdsFile.getDirectory(context), noteIds, IdsFile.Purpose.TAGS) arguments = this.arguments ?: Bundle().apply { putParcelable(ARG_TAGS_FILE, file) putParcelable(ARG_DIALOG_TYPE, type) @@ -161,7 +162,6 @@ class TagsDialog : AnalyticsDialogFragment { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - resizeWhenSoftInputShown(requireActivity().window) type = requireArguments().requireParcelable(ARG_DIALOG_TYPE) isCancelable = true @@ -225,7 +225,7 @@ class TagsDialog : AnalyticsDialogFragment { lifecycleScope.launch { val showProgressJob = launch { - delay(600) + delay(600.milliseconds) withContext(Dispatchers.Main) { binding.loadingContainer.visibility = View.VISIBLE viewModel.initProgress @@ -266,7 +266,7 @@ class TagsDialog : AnalyticsDialogFragment { positiveButton?.isEnabled = true } - dialog.window?.updateSoftInputMode(resources.configuration) + dialog.updateSoftInputMode(resources.configuration) return dialog } @@ -307,15 +307,11 @@ class TagsDialog : AnalyticsDialogFragment { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - dialog?.window?.updateSoftInputMode(newConfig) + dialog?.updateSoftInputMode(newConfig) } - private fun Window.updateSoftInputMode(configuration: Configuration) { - if (configuration.screenHeightDp < COMPACT_HEIGHT_DP) { - setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) - } else { - resizeWhenSoftInputShown(this) - } + private fun Dialog.updateSoftInputMode(configuration: Configuration) { + setDialogKeyboardResize(this, resize = configuration.screenHeightDp >= COMPACT_HEIGHT_DP) } private fun radioButtonIdToCardState(id: Int) = @@ -335,7 +331,7 @@ class TagsDialog : AnalyticsDialogFragment { toolbar.setTitle(titleRes) val toolbarAddItem = toolbar.menu.findItem(R.id.tags_dialog_action_add) - val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_add) + val drawable = ContextCompat.getDrawable(requireContext(), CommonR.drawable.ic_add) drawable?.setTint(requireContext().getColor(R.color.white)) toolbarAddItem.icon = drawable diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogFactory.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogFactory.kt index 92a9102725c2..ddf9698590f5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogFactory.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogFactory.kt @@ -1,18 +1,6 @@ -/* - Copyright (c) 2021 Tarek Mohamed +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Tarek Mohamed - 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.dialogs.tags import androidx.annotation.MainThread diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt index 41114b70079f..3de706901c52 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt @@ -1,18 +1,6 @@ -/* - Copyright (c) 2021 Tarek Mohamed Abdalla +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Tarek Mohamed Abdalla - 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.dialogs.tags import android.os.Bundle diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt index 63724b6d255e..07c928de51d4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt @@ -1,18 +1,6 @@ -/* - Copyright (c) 2021 Tarek Mohamed Abdalla +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Tarek Mohamed Abdalla - 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.dialogs.tags import com.ichi2.utils.TagsUtil.compareTag @@ -42,7 +30,7 @@ class TagsList( /** * A Set containing the currently selected tags */ - private val checkedTags: MutableSet = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) + private val checkedTags = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) /** * Tags present on some notes, plus unchecked ancestors of selected tags. @@ -50,7 +38,7 @@ class TagsList( private val indeterminateTags: MutableSet /** Tags present on some notes whose own selection has not been changed by the user. */ - private val partiallySelectedTags: MutableSet = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) + private val partiallySelectedTags = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) /** * List of all available tags @@ -114,6 +102,17 @@ class TagsList( */ fun isIndeterminate(tag: String): Boolean = indeterminateTags.contains(tag) + /** Whether the tag itself is partially selected, independently of its descendants. */ + internal fun isPartiallySelected(tag: String): Boolean = partiallySelectedTags.contains(tag) + + /** Checks the complete selection, including descendants hidden by the dialog's filter. */ + internal fun hasSelectedDescendants(tag: String): Boolean { + val prefix = "$tag::" + // Descendants are contiguous in these case-insensitive sets; only the first candidate is needed. + return checkedTags.ceiling(prefix)?.startsWith(prefix, ignoreCase = true) == true || + partiallySelectedTags.ceiling(prefix)?.startsWith(prefix, ignoreCase = true) == true + } + /** * Adds a tag to the list if it is not already present. * If the tag is hierarchical, its ancestors will also be added temporarily. @@ -258,7 +257,9 @@ class TagsList( for (tag in allTags) { addAncestors(tag) } - for (tag in checkedTags) { + // Partially selected descendants also need their ancestors prioritized and expanded. + // Take a snapshot because marking ancestors modifies indeterminateTags. + for (tag in checkedTags + indeterminateTags) { markAncestorsIndeterminate(tag) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/TtsVoicesViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/TtsVoicesViewModel.kt index 8717a73bf530..c4abeaa7b6b7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/TtsVoicesViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/TtsVoicesViewModel.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber +import kotlin.time.Duration.Companion.seconds /** * @see [com.ichi2.anki.dialogs.TtsVoicesDialogFragment] @@ -157,7 +158,7 @@ class TtsVoicesViewModel : ViewModel() { // and don't perform updates when no changes occur Timber.v("voice list refreshed") ttsVoiceListStatus.emit(LoadVoiceStatus.Success(voices)) - delay(2_000) + delay(2.seconds) } } addCloseable { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/exception/AnrReportException.kt b/AnkiDroid/src/main/java/com/ichi2/anki/exception/AnrReportException.kt new file mode 100644 index 000000000000..6ba1849b3a9d --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/exception/AnrReportException.kt @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.exception + +/** + * Represents an ANR recorded by Android during a previous app run. + * + * Uses the main-thread stack captured by Android during the ANR, or an empty stack if decoding fails. + * + * @param stackTrace Captured main-thread frames from the previous app run, or null if decoding failed. + */ +internal class AnrReportException( + stackTrace: Array?, +) : Exception( + (if (stackTrace == null) "Previous ANR: unable to decode main-thread trace." else "Previous ANR.") + + "\nOther report metadata describes this reporting launch.", + // cause + null, + // enable suppression + false, + // a writable stack trace is required + true, + ) { + init { + this.stackTrace = stackTrace ?: emptyArray() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/exception/MediaSizeLimitExceededException.kt b/AnkiDroid/src/main/java/com/ichi2/anki/exception/MediaSizeLimitExceededException.kt index 7ebb84731e1f..bce8326c1d77 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/exception/MediaSizeLimitExceededException.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/exception/MediaSizeLimitExceededException.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2026 Ujjol Chakrabarty - * - * 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) 2026 Ujjol Chakrabarty + package com.ichi2.anki.exception import android.content.Context diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/exception/UserSubmittedException.kt b/AnkiDroid/src/main/java/com/ichi2/anki/exception/UserSubmittedException.kt index ab764a9ba721..aa7e372eaa76 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/exception/UserSubmittedException.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/exception/UserSubmittedException.kt @@ -1,4 +1,5 @@ -//noinspection MissingCopyrightHeader #8659 +// SPDX-License-Identifier: GPL-3.0-or-later + package com.ichi2.anki.exception import java.lang.RuntimeException diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt index 8bc56f41a9fa..fba99888aa88 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt @@ -468,11 +468,11 @@ class ExportDialogFragment : AnalyticsDialogFragment() { * Create a new instance of this dialog targeting a selection of cards or notes for export. */ fun newInstance( - cacheDir: File, + idsFileDirectory: File, type: ExportType, ids: List, ) = ExportDialogFragment().apply { - val idsFile = IdsFile(cacheDir, ids, "export") + val idsFile = IdsFile(idsFileDirectory, ids, IdsFile.Purpose.EXPORT) arguments = Bundle().apply { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/i18n/LocaleCodes.kt b/AnkiDroid/src/main/java/com/ichi2/anki/i18n/LocaleCodes.kt index 5009b7d6a548..c917301deb63 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/i18n/LocaleCodes.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/i18n/LocaleCodes.kt @@ -15,16 +15,15 @@ import java.util.Locale * * Anki on other platforms expects standardized locales: `{{tts es_ES:field}}`, rather than `spa` */ -private val twoLetterSystemLocaleMapping: Map = - run { - val locales = Locale.getAvailableLocales() - val validLocales = mutableMapOf() - for (locale in locales) { - val code = locale.iso3Code ?: continue - validLocales.putIfAbsent(code, locale) - } - validLocales +private val twoLetterSystemLocaleMapping: Map by lazy { + val locales = Locale.getAvailableLocales() + val validLocales = mutableMapOf() + for (locale in locales) { + val code = locale.iso3Code ?: continue + validLocales.putIfAbsent(code, locale) } + validLocales +} /** * Returns an Anki-compatible 'two letter' code (ISO-639-1 + ISO 3166-1 [alpha-2 preferred]) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/EditorChips.kt b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/EditorChips.kt index f5481bea1dbf..f393dd3a7a20 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/EditorChips.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/EditorChips.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.instantnoteeditor diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt index f323558175ce..1d19d84d465d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.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.instantnoteeditor diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/logging/ProductionCrashReportingTree.kt b/AnkiDroid/src/main/java/com/ichi2/anki/logging/ProductionCrashReportingTree.kt index 4b65e30295b0..2918ebf1b66b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/logging/ProductionCrashReportingTree.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/logging/ProductionCrashReportingTree.kt @@ -66,8 +66,8 @@ class ProductionCrashReportingTree : Timber.Tree() { when (priority) { Log.VERBOSE, Log.DEBUG -> {} Log.INFO -> Log.i(AnkiDroidApp.TAG, message, t) - Log.WARN -> Log.w(AnkiDroidApp.TAG, "${this.tag}/ $message", t) - Log.ERROR, Log.ASSERT -> Log.e(AnkiDroidApp.TAG, "${this.tag}/ $message", t) + Log.WARN -> Log.w(AnkiDroidApp.TAG, "${tag ?: this.tag}/ $message", t) + Log.ERROR, Log.ASSERT -> Log.e(AnkiDroidApp.TAG, "${tag ?: this.tag}/ $message", t) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/logging/TimberServiceProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/logging/TimberServiceProvider.kt new file mode 100644 index 000000000000..09706a42f68d --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/logging/TimberServiceProvider.kt @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2026 Ashish Yadav + +package com.ichi2.anki.logging + +import android.util.Log +import org.slf4j.ILoggerFactory +import org.slf4j.IMarkerFactory +import org.slf4j.Logger +import org.slf4j.Marker +import org.slf4j.event.Level +import org.slf4j.helpers.BasicMarkerFactory +import org.slf4j.helpers.LegacyAbstractLogger +import org.slf4j.helpers.MessageFormatter +import org.slf4j.helpers.NOPMDCAdapter +import org.slf4j.spi.MDCAdapter +import org.slf4j.spi.SLF4JServiceProvider +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +/** Sends SLF4J logs to Timber. SLF4J finds it through `META-INF/services`. */ +class TimberServiceProvider : SLF4JServiceProvider { + private val loggerFactory = TimberLoggerFactory() + private val markerFactory = BasicMarkerFactory() + private val mdcAdapter = NOPMDCAdapter() + + override fun getLoggerFactory(): ILoggerFactory = loggerFactory + + override fun getMarkerFactory(): IMarkerFactory = markerFactory + + override fun getMDCAdapter(): MDCAdapter = mdcAdapter + + override fun getRequestedApiVersion(): String = "2.0.99" + + override fun initialize() {} +} + +private class TimberLoggerFactory : ILoggerFactory { + private val loggers = ConcurrentHashMap() + + override fun getLogger(name: String): Logger = loggers.computeIfAbsent(name) { TimberLogger(it) } +} + +private class TimberLogger( + name: String, +) : LegacyAbstractLogger() { + private val tag = name.substringAfterLast('.') + + init { + this.name = name + } + + override fun isTraceEnabled() = true + + override fun isDebugEnabled() = true + + override fun isInfoEnabled() = true + + override fun isWarnEnabled() = true + + override fun isErrorEnabled() = true + + override fun getFullyQualifiedCallerName(): String? = null + + override fun handleNormalizedLoggingCall( + level: Level, + marker: Marker?, + messagePattern: String?, + arguments: Array?, + throwable: Throwable?, + ) { + val message = MessageFormatter.basicArrayFormat(messagePattern, arguments) + Timber.tag(tag).log(level.priority, throwable, message) + } +} + +private val Level.priority: Int + get() = + when (this) { + Level.TRACE -> Log.VERBOSE + Level.DEBUG -> Log.DEBUG + Level.INFO -> Log.INFO + Level.WARN -> Log.WARN + Level.ERROR -> Log.ERROR + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt index f7ad03c8f980..c1f8e8a9fab6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt @@ -23,13 +23,15 @@ import androidx.lifecycle.lifecycleScope import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonPlurals import com.ichi2.anki.CommonString +import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.R import com.ichi2.anki.SingleFragmentActivity import com.ichi2.anki.common.utils.android.getColorFromAttr import com.ichi2.anki.databinding.FragmentMediaCheckBinding -import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.ui.internationalization.sentenceCase +import com.ichi2.anki.utils.observeMessages +import com.ichi2.anki.utils.showAndAwaitDismissal import com.ichi2.utils.cancelable import com.ichi2.utils.message import com.ichi2.utils.negativeButton @@ -66,7 +68,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { (requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar) observeProgress(viewModel) { progress -> getString(progress.messageRes) } - launchCatchingTask { viewModel.checkMedia().await() } + viewModel.checkMedia() lifecycleScope.launch { viewModel.mediaCheckResult.collectLatest { result -> @@ -79,6 +81,8 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { } } + observeMessages(viewModel.pendingMessages, viewModel::messageShown, ::showMessage) + setupButtonListeners() } @@ -104,11 +108,11 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_restore_trash -> { - confirmMediaRestore() + viewModel.restoreTrash() true } R.id.action_empty_trash -> { - deleteTrash() + viewModel.deleteTrash() true } else -> false @@ -148,13 +152,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { text = TR.sentenceCase.tagMissing setOnClickListener { - launchCatchingTask { - viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).await() - showResultDialog( - CommonString.check_media_tags_added, - TR.browsingNotesUpdated(viewModel.taggedFiles), - ) - } + viewModel.tagMissing(tag = TR.mediaCheckMissingMediaTag()) } } @@ -167,87 +165,53 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { } } - private fun confirmMediaRestore() { - launchCatchingTask { - viewModel.restoreTrash().await() - showTrashRestoredDialog() - } - } - - private fun deleteTrash() { - launchCatchingTask { - viewModel.deleteTrash().await() - showTrashDeletedDialog() - } - } - private fun deleteConfirmationDialog() { AlertDialog.Builder(requireContext()).show { message(text = TR.mediaCheckDeleteUnusedConfirm()) - positiveButton(CommonString.dialog_positive_delete) { handleDeleteConfirmation() } + positiveButton(CommonString.dialog_positive_delete) { viewModel.deleteUnusedMedia() } negativeButton(CommonString.dialog_cancel) } } - private fun handleDeleteConfirmation() { - launchCatchingTask { - viewModel.deleteUnusedMedia().await() - showDeletionResult() - } - } - - /** - * Displays the result of a media deletion operation and updates stored trash statistics. - * - * This function retrieves the previously stored trash information (if any), - * combines it with the current deletion statistics from the ViewModel, and - * updates the stored values accordingly. - */ - private fun showDeletionResult() { - showResultDialog( - CommonString.delete_media_result_title, - resources.getQuantityString( - CommonPlurals.delete_media_result_message, - viewModel.deletedFiles, - viewModel.deletedFiles, - ), - ) - } - - private fun showTrashRestoredDialog() { - AlertDialog.Builder(requireContext()).show { - message(text = TR.mediaCheckTrashRestored()) - positiveButton(CommonString.dialog_ok) { - requireActivity().finish() - } - cancelable(false) - } - } - - private fun showTrashDeletedDialog() { - AlertDialog.Builder(requireContext()).show { - message(text = TR.mediaCheckTrashEmptied()) - positiveButton(CommonString.dialog_ok) { - requireActivity().finish() - } - cancelable(false) + private suspend fun showMessage(message: MediaCheckMessage) { + when (message) { + is MediaCheckMessage.TagsAdded -> + showResultDialog( + CommonString.check_media_tags_added, + TR.browsingNotesUpdated(message.notesUpdated), + ) + is MediaCheckMessage.MediaDeleted -> + showResultDialog( + CommonString.delete_media_result_title, + resources.getQuantityString(CommonPlurals.delete_media_result_message, message.count, message.count), + ) + MediaCheckMessage.TrashEmptied -> showResultDialog(message = TR.mediaCheckTrashEmptied()) + MediaCheckMessage.TrashRestored -> showResultDialog(message = TR.mediaCheckTrashRestored()) + is MediaCheckMessage.Failed -> showErrorDialog(message.exception) } } - private fun showResultDialog( - titleRes: Int, + private suspend fun showResultDialog( + @StringRes titleRes: Int? = null, message: String, ) { - AlertDialog.Builder(requireContext()).show { - title(titleRes) + AlertDialog.Builder(requireContext()).showAndAwaitDismissal { + titleRes?.let { title(it) } message(text = message) - positiveButton(CommonString.dialog_ok) { - requireActivity().finish() - } + positiveButton(CommonString.dialog_ok) { requireActivity().finish() } cancelable(false) } } + private suspend fun showErrorDialog(exception: Exception) { + AlertDialog.Builder(requireContext()).showAndAwaitDismissal { + title(CommonString.vague_error) + message(text = exception.localizedMessage ?: exception.toString()) + positiveButton(CommonString.dialog_ok) + } + exception.toCrashReportData(requireActivity()).sendCrashReport() + } + companion object { fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, MediaCheckFragment::class) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt index 876803dc3375..079917a76fac 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt @@ -1,19 +1,5 @@ -/* - * Copyright (c) 2025 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) 2025 Ashish Yadav package com.ichi2.anki.mediacheck @@ -25,10 +11,14 @@ import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.progress.HasProgress import com.ichi2.anki.progress.ProgressManager -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async +import com.ichi2.anki.utils.MessageQueue +import com.ichi2.anki.utils.MessageQueue.MessageId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber /** The operation [MediaCheckViewModel] is running, shown as a progress message by the UI. */ enum class MediaCheckProgress { @@ -37,7 +27,25 @@ enum class MediaCheckProgress { DELETING_MEDIA, } -// TODO: handle errors in the ViewModel instead of returning a Deferred for the UI to await +/** The outcome of a [MediaCheckViewModel] operation, which the UI shows and then acknowledges. */ +sealed interface MediaCheckMessage { + data class TagsAdded( + val notesUpdated: Int, + ) : MediaCheckMessage + + data class MediaDeleted( + val count: Int, + ) : MediaCheckMessage + + data object TrashEmptied : MediaCheckMessage + + data object TrashRestored : MediaCheckMessage + + data class Failed( + val exception: Exception, + ) : MediaCheckMessage +} + @NeedsTest("Test the media check process i.e. the buttons and views") class MediaCheckViewModel : ViewModel(), @@ -47,50 +55,58 @@ class MediaCheckViewModel : val mediaCheckResult: StateFlow field = MutableStateFlow(null) - private val deletedFilesCount: MutableStateFlow = MutableStateFlow(0) - private val taggedFilesCount: MutableStateFlow = MutableStateFlow(0) + private val messageQueue = MessageQueue() - val deletedFiles: Int - get() = deletedFilesCount.value + val pendingMessages = messageQueue.messages - val taggedFiles: Int - get() = taggedFilesCount.value + fun messageShown(id: MessageId) = messageQueue.acknowledge(id) - fun tagMissing(tag: String): Deferred = - viewModelScope.async { - progressManager.withProgress(message = MediaCheckProgress.ADDING_TAGS) { - val taggedNotes = - undoableOp { - tags.bulkAdd(mediaCheckResult.value?.missingMediaNotesList ?: listOf(), tag) - } - taggedFilesCount.value = taggedNotes.count - } + fun tagMissing(tag: String): Job = + launchOperation(MediaCheckProgress.ADDING_TAGS) { + val taggedNotes = + undoableOp { + tags.bulkAdd(mediaCheckResult.value?.missingMediaNotesList ?: listOf(), tag) + } + messageQueue.enqueue(MediaCheckMessage.TagsAdded(taggedNotes.count)) } - fun checkMedia(): Deferred = - viewModelScope.async { - progressManager.withProgress(message = MediaCheckProgress.CHECKING_MEDIA) { - mediaCheckResult.value = withCol { media.check() } - } + fun checkMedia(): Job = + launchOperation(MediaCheckProgress.CHECKING_MEDIA) { + mediaCheckResult.value = withCol { media.check() } } - fun deleteTrash(): Deferred = - viewModelScope.async { - progressManager.withProgress { withCol { media.emptyTrash() } } + fun deleteTrash(): Job = + launchOperation { + withCol { media.emptyTrash() } + messageQueue.enqueue(MediaCheckMessage.TrashEmptied) } - fun restoreTrash(): Deferred = - viewModelScope.async { - progressManager.withProgress { withCol { media.restoreTrash() } } + fun restoreTrash(): Job = + launchOperation { + withCol { media.restoreTrash() } + messageQueue.enqueue(MediaCheckMessage.TrashRestored) } // TODO: investigate: the underlying implementation exposes progress, which we do not yet handle. - fun deleteUnusedMedia(): Deferred = - viewModelScope.async { - progressManager.withProgress(message = MediaCheckProgress.DELETING_MEDIA) { - val unused = mediaCheckResult.value?.unusedList ?: listOf() - withCol { media.trashFiles(unused) } - deletedFilesCount.value = unused.size + fun deleteUnusedMedia(): Job = + launchOperation(MediaCheckProgress.DELETING_MEDIA) { + val unused = mediaCheckResult.value?.unusedList ?: listOf() + withCol { media.trashFiles(unused) } + messageQueue.enqueue(MediaCheckMessage.MediaDeleted(unused.size)) + } + + private fun launchOperation( + progress: MediaCheckProgress? = null, + block: suspend () -> Unit, + ): Job = + viewModelScope.launch { + try { + progressManager.withProgress(message = progress) { block() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w("Media check operation failed") + messageQueue.enqueue(MediaCheckMessage.Failed(e)) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioRecordingFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioRecordingFragment.kt index ce01fd42e5ee..b64f653d1b69 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioRecordingFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioRecordingFragment.kt @@ -1,50 +1,62 @@ -/* - * 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.multimedia import android.Manifest import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.os.Bundle +import android.view.Gravity import android.view.View +import android.widget.LinearLayout +import android.widget.ScrollView import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.ichi2.anki.CommonString import com.ichi2.anki.R -import com.ichi2.anki.common.crashreporting.CrashReportService +import com.ichi2.anki.common.time.formatAsString +import com.ichi2.anki.common.utils.android.showThemedToast +import com.ichi2.anki.compat.CompatHelper.Companion.compat +import com.ichi2.anki.compat.USAGE_TOUCH +import com.ichi2.anki.databinding.ActivityAudioRecordingBinding import com.ichi2.anki.databinding.FragmentAudioRecordingBinding -import com.ichi2.anki.multimedia.audio.AudioRecordingController +import com.ichi2.anki.noteeditor.AudioRecordingViewModel +import com.ichi2.anki.noteeditor.AudioRecordingViewModel.Failure +import com.ichi2.anki.recorder.PlayerState +import com.ichi2.anki.recorder.RecorderState +import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.ui.OnHoldListener +import com.ichi2.anki.ui.setOnHoldListener +import com.ichi2.ui.FixedTextView import com.ichi2.utils.FileUtil import com.ichi2.utils.Permissions +import com.ichi2.utils.UiUtil import dev.androidbroadcast.vbpd.viewBinding +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds class AudioRecordingFragment : MultimediaFragment(R.layout.fragment_audio_recording) { @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val binding by viewBinding(FragmentAudioRecordingBinding::bind) + private val recordingViewModel: AudioRecordingViewModel by viewModels() + + private var recorderBinding: ActivityAudioRecordingBinding? = null + override val title: String get() = resources.getString(CommonString.multimedia_editor_field_editing_audio) - private var audioRecordingController: AudioRecordingController? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ankiCacheDirectory = FileUtil.getAnkiCacheDirectory(requireContext(), "temp-media") @@ -84,6 +96,24 @@ class AudioRecordingFragment : MultimediaFragment(R.layout.fragment_audio_record setupDoneButton() } + override fun onStop() { + super.onStop() + recorderBinding?.root?.keepScreenOn = false + if (!requireActivity().isChangingConfigurations) { + recordingViewModel.interrupt() + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + recorderBinding?.applyOrientation(newConfig) + } + + override fun onDestroyView() { + super.onDestroyView() + recorderBinding = null + } + private fun hasMicPermission(): Boolean { if (!Permissions.canRecordAudio(requireContext())) { Timber.i("Requesting Audio Permissions") @@ -94,50 +124,229 @@ class AudioRecordingFragment : MultimediaFragment(R.layout.fragment_audio_record } private fun setupDoneButton() { - lifecycleScope.launch { - viewModel.currentMultimediaPath.collect { path -> - binding.actionDone.isEnabled = path != null - } - } binding.actionDone.setOnClickListener { Timber.d("AudioRecordingFragment:: Done button pressed") - if (viewModel.selectedMediaFileSize == 0L) { - Timber.d("Audio length not valid") - return@setOnClickListener + recordingViewModel.consumeRecording { file -> + field.mediaFile = file + field.hasTemporaryMedia = true + setMultimediaResultAndFinish(MultimediaResult.Success(indexValue, field)) } - - finishWithMedia() } } private fun initializeAudioRecorder() { - if (audioRecordingController != null) return - Timber.d("Initialising AudioRecordingController") - try { - audioRecordingController = - AudioRecordingController( - context = requireActivity(), - linearLayout = binding.audioRecorderLayout, - viewModel = viewModel, - note = note, - ) - } catch (e: Exception) { - Timber.w(e, "unable to add the audio recorder to toolbar") - CrashReportService.sendExceptionReport(e, "Unable to create recorder tool bar") - showErrorDialog() + if (recorderBinding != null) return + Timber.d("Initialising the audio recorder") + val recorder = ActivityAudioRecordingBinding.inflate(layoutInflater, binding.audioRecorderLayout, true) + recorderBinding = recorder + addFieldPreview() + recorder.applyOrientation(resources.configuration) + recorder.setupListeners() + recorder.observeViewModel() + } + + private fun addFieldPreview() { + val context = requireContext() + val scrollView = ScrollView(context) + binding.audioRecorderLayout.addView(scrollView) + val previewLayout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + scrollView.addView(previewLayout) + val label = + FixedTextView(context).apply { + textSize = 20f + text = UiUtil.makeBold(getString(CommonString.audio_recording_field_list)) + gravity = Gravity.CENTER_HORIZONTAL + } + previewLayout.addView(label) + var hasTextContents = false + for (i in 0 until note.initialFieldCount) { + val field = note.getInitialField(i) + FixedTextView(context).apply { + text = field?.text + textSize = 16f + setPaddingRelative(16, 0, 16, 24) + previewLayout.addView(this) + } + hasTextContents = hasTextContents or !field?.text.isNullOrBlank() } + label.isVisible = hasTextContents } - override fun onDestroy() { - super.onDestroy() - audioRecordingController?.onDestroy() + private fun ActivityAudioRecordingBinding.setupListeners() { + actionStartRecording.isHapticFeedbackEnabled = false + actionStartRecording.setOnHoldListener( + object : OnHoldListener { + override fun onTouchStart() { + Timber.i("'record' pressed") + onRecordPressed() + } + + override fun onHoldEnd() { + Timber.i("'record' released after holding") + saveRecording() + } + }, + ) + actionSaveRecording.setIconResource(R.drawable.ic_save_white) + actionSaveRecording.setOnClickListener { + Timber.i("'save' clicked") + saveRecording() + } + actionCancelRecording.setOnClickListener { + Timber.i("'cancel recording' clicked") + discardRecording() + } + actionDiscardRecording.setOnClickListener { + Timber.i("'discard recording' clicked") + discardRecording() + } + actionPlayRecording.setOnClickListener { + Timber.i("play/pause clicked") + togglePlayback() + } + actionRewind.setOnClickListener { + Timber.i("'back' clicked") + recordingViewModel.skipBack() + } + actionForward.setOnClickListener { + Timber.i("'forward' clicked") + recordingViewModel.skipForward() + } } - override fun onDetach() { - super.onDetach() - audioRecordingController?.onFocusLost() + private fun onRecordPressed() { + when (recordingViewModel.recorderState.value) { + RecorderState.Idle -> recordingViewModel.startRecording() + RecorderState.Recording -> { + recordingViewModel.pauseRecording() + vibrate() + } + RecorderState.Paused -> recordingViewModel.resumeRecording() + is RecorderState.Recorded -> {} + } } + private fun saveRecording() { + val state = recordingViewModel.recorderState.value + if (state != RecorderState.Recording && state != RecorderState.Paused) return + recordingViewModel.stopRecording() + vibrate() + if (recordingViewModel.recorderState.value is RecorderState.Recorded) { + showSnackbar(CommonString.audio_saved) + } + } + + private fun discardRecording() { + recordingViewModel.discard() + vibrate() + } + + private fun togglePlayback() { + val state = recordingViewModel.playerState.value + if (state is PlayerState.Ready && state.isPlaying) { + recordingViewModel.pausePlayback() + } else { + recordingViewModel.play() + } + } + + private fun ActivityAudioRecordingBinding.observeViewModel() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + recordingViewModel.recorderState + .combine(recordingViewModel.playerState) { recorderState, playerState -> recorderState to playerState } + .collect { (recorderState, playerState) -> render(recorderState, playerState) } + } + launch { + recordingViewModel.elapsed.collect { elapsed -> + if (recordingViewModel.recorderState.value !is RecorderState.Recorded) { + audioTimeTrack.text = elapsed.formatAsString() + } + } + } + launch { + recordingViewModel.playbackPosition.collect { position -> + if (recordingViewModel.recorderState.value is RecorderState.Recorded) { + audioTimeTrack.text = position.formatAsString() + audioProgressIndicator.progress = position.inWholeMilliseconds.toInt() + } + } + } + launch { + recordingViewModel.amplitudes.collect { amplitude -> + if (amplitude != null) audioWaveformView.addAmplitude((amplitude / 10).toFloat()) + } + } + launch { + recordingViewModel.failure.filterNotNull().collect { failure -> + showFailure(failure) + recordingViewModel.failureShown() + } + } + } + } + } + + private fun ActivityAudioRecordingBinding.render( + recorderState: RecorderState, + playerState: PlayerState, + ) { + val isRecorded = recorderState is RecorderState.Recorded + val isRecordingOrPaused = recorderState == RecorderState.Recording || recorderState == RecorderState.Paused + val isPlaying = playerState is PlayerState.Ready && playerState.isPlaying + + recordButtonsLayout.isVisible = !isRecorded + playButtonsLayout.isVisible = isRecorded + actionCancelRecording.isEnabled = isRecordingOrPaused + actionSaveRecording.isEnabled = isRecordingOrPaused + actionStartRecording.setIconResource( + if (recorderState == RecorderState.Recording) R.drawable.round_pause_24 else R.drawable.ic_record, + ) + if (!isRecordingOrPaused) audioWaveformView.clear() + + actionRewind.isEnabled = isPlaying + actionForward.isEnabled = isPlaying + actionPlayRecording.setIconResource(if (isPlaying) R.drawable.round_pause_24 else R.drawable.round_play_arrow_24) + val playTint = + ContextCompat.getColorStateList(requireContext(), if (isPlaying) R.color.audio_recorder_green else R.color.audio_recorder_red) + actionPlayRecording.iconTint = playTint + actionPlayRecording.strokeColor = playTint + audioProgressIndicator.max = + ((playerState as? PlayerState.Ready)?.duration?.inWholeMilliseconds ?: 0).toInt().coerceAtLeast(1) + val position = recordingViewModel.playbackPosition.value + audioProgressIndicator.progress = position.inWholeMilliseconds.toInt() + audioTimeTrack.text = (if (isRecorded) position else recordingViewModel.elapsed.value).formatAsString() + + root.keepScreenOn = recorderState == RecorderState.Recording || isPlaying + binding.actionDone.isEnabled = isRecorded + viewModel.updateCurrentMultimediaPath((recorderState as? RecorderState.Recorded)?.file) + } + + private fun showFailure(failure: Failure) { + val message = + when (failure) { + Failure.RECORDING -> CommonString.multimedia_editor_audio_view_recording_failed + Failure.PLAYBACK -> CommonString.multimedia_editor_audio_view_playing_failed + } + showThemedToast(requireContext(), getString(message), true) + } + + private fun ActivityAudioRecordingBinding.applyOrientation(configuration: Configuration) { + when (configuration.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + audioFileImageview.isVisible = false + audioWaveformView.isVisible = false + } + Configuration.ORIENTATION_PORTRAIT -> { + audioFileImageview.isVisible = true + audioWaveformView.isVisible = true + } + } + } + + private fun vibrate() = compat.vibrate(requireContext(), 20.milliseconds, USAGE_TOUCH) + companion object { fun getIntent( context: Context, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt index 2a1ef4a55d46..5e7d245c583d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.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.multimedia diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/ImageEditorToolbar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/ImageEditorToolbar.kt new file mode 100644 index 000000000000..871e7c6a9b6e --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/ImageEditorToolbar.kt @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.multimedia + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.ichi2.anki.CommonString +import com.ichi2.anki.R +import com.ichi2.compose.ui.preview.AnkiDroidPreview +import com.ichi2.compose.ui.preview.ThemePreviews + +/** + * Image actions hosted below the preview: + * + * * Replace + * * Crop + */ +@Composable +internal fun ImageEditorToolbar( + hasImage: Boolean, + @DrawableRes replaceIcon: Int, + onReplace: () -> Unit, + onCrop: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), contentAlignment = Alignment.Center) { + Surface( + modifier = Modifier.widthIn(max = 240.dp).fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row(modifier = Modifier.padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + ImageEditorAction(CommonString.dialog_positive_replace, replaceIcon, onReplace, Modifier.weight(1f)) + ImageEditorDivider() + ImageEditorAction(CommonString.crop_button, R.drawable.ic_crop, onCrop, Modifier.weight(1f), enabled = hasImage) + } + } + } +} + +@Composable +private fun ImageEditorAction( + @StringRes label: Int, + @DrawableRes icon: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + TextButton( + onClick = onClick, + modifier = modifier.heightIn(min = 64.dp), + enabled = enabled, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp), + colors = ButtonDefaults.textButtonColors(contentColor = LocalContentColor.current), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Icon(painterResource(icon), contentDescription = null, modifier = Modifier.size(22.dp)) + Text(stringResource(label), style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center) + } + } +} + +@Composable +private fun ImageEditorDivider() { + Box(modifier = Modifier.height(64.dp), contentAlignment = Alignment.Center) { + VerticalDivider( + modifier = Modifier.height(32.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} + +@ThemePreviews +@Composable +private fun ImageEditorToolbarPreview() { + ImageEditorToolbarPreviewContent(hasImage = true) +} + +@ThemePreviews +@Composable +private fun EmptyImageEditorToolbarPreview() { + ImageEditorToolbarPreviewContent(hasImage = false) +} + +@Composable +private fun ImageEditorToolbarPreviewContent(hasImage: Boolean) { + AnkiDroidPreview { + ImageEditorToolbar(hasImage = hasImage, replaceIcon = R.drawable.ic_photo_library, onReplace = {}, onCrop = {}) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActionHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActionHandler.kt index 7b77bea082ef..dc0bcfe0195f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActionHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActionHandler.kt @@ -1,19 +1,5 @@ -/* - * Copyright (c) 2026 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) 2026 Ashish Yadav package com.ichi2.anki.multimedia diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt index a6b45303d23f..78ddc8436212 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt @@ -7,14 +7,16 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.os.Bundle +import android.view.View import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.commit -import com.google.android.material.button.MaterialButton import com.ichi2.anki.AnkiActivity import com.ichi2.anki.R import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat @@ -26,6 +28,8 @@ import com.ichi2.anki.settings.enums.NightTheme import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.startup.ensureStorageIsReady +import com.ichi2.compose.theme.AnkiDroidTheme +import com.ichi2.compose.ui.components.HeroToolbarActionButton import com.ichi2.themes.Themes import com.ichi2.utils.FragmentFactoryUtils import dev.androidbroadcast.vbpd.viewBinding @@ -84,6 +88,7 @@ class MultimediaActivity : } setupEdgeToEdge() setSupportActionBar(binding.toolbar) + setupToolbarActions() // avoid recreating the fragment on configuration changes if (savedInstanceState != null) { @@ -114,6 +119,23 @@ class MultimediaActivity : } } + private fun setupToolbarActions() { + binding.toolbarActions.isVisible = intent.getStringExtra(EXTRA_FRAGMENT_NAME) == MultimediaImageFragment::class.jvmName + if (!binding.toolbarActions.isVisible) return + + binding.toolbarActions.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AnkiDroidTheme { + HeroToolbarActionButton(onClick = { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) as? MultimediaImageFragment + fragment?.onDone() + }) + } + } + } + } + /** * Applies edge-to-edge insets for the app bar. The hosted fragment insets its own root, * see [MultimediaFragment]. @@ -130,14 +152,8 @@ class MultimediaActivity : } override val baseSnackbarBuilder: SnackbarBuilder = { - // if action_done exists in a fragment, use that as the anchor view - - // This is a minor hack architecturally: AudioRecordingController should request that - // the host fragment/activity opens a snackbar, so the activity doesn't need knowledge - // of the layout of its hosted fragments - - // If this doesn't work, anchorView remains null - anchorView = findViewById(R.id.action_done) + // Keep error messages above the editor's bottom controls. + anchorView = findViewById(R.id.image_actions) ?: findViewById(R.id.action_done) } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaBottomSheet.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaBottomSheet.kt index 042fbc52782f..002416ad3eb9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaBottomSheet.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaBottomSheet.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.multimedia diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt index a7fc7e17b099..8a6b54c8cc0c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt @@ -20,10 +20,13 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString @@ -45,6 +48,7 @@ import com.ichi2.anki.utils.ext.toBase64Png import com.ichi2.anki.workarounds.OnWebViewRecreatedListener import com.ichi2.anki.workarounds.SafeWebViewClient import com.ichi2.anki.workarounds.SafeWebViewLayout +import com.ichi2.compose.theme.AnkiDroidTheme import com.ichi2.imagecropper.ImageCropper import com.ichi2.imagecropper.ImageCropper.Companion.CROP_IMAGE_RESULT import com.ichi2.utils.BitmapUtil @@ -58,7 +62,6 @@ import com.ichi2.utils.show import dev.androidbroadcast.vbpd.viewBinding import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -190,56 +193,45 @@ class MultimediaImageFragment : } } - /** - * Lazily initialized instance of MultimediaMenu. - * The instance is created only when first accessed. - */ - private val multimediaMenu by lazy { - MultimediaMenuProvider( - menuResId = R.menu.multimedia_menu, - onCreateMenuCondition = { menu -> - - setMenuItemIcon(menu.findItem(R.id.action_restart), R.drawable.ic_replace_image) - lifecycleScope.launch { - viewModel.currentMultimediaUri.collectLatest { uri -> - menu.findItem(R.id.action_crop).isVisible = uri != null - } - } - }, - ) { menuItem -> - when (menuItem.itemId) { - R.id.action_crop -> { - viewModel.saveMultimediaForRevert( - imagePath = viewModel.currentMultimediaPath.value, - imageUri = viewModel.currentMultimediaUri.value, - ) - requestCrop() - true - } - - R.id.action_restart -> { - when (selectedImageOptions) { - ImageOptions.GALLERY -> { - openGallery() - } - - ImageOptions.CAMERA -> { + private fun setupImageActions() { + binding.imageActions.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AnkiDroidTheme { + val imageUri by viewModel.currentMultimediaUri.collectAsStateWithLifecycle() + ImageEditorToolbar( + hasImage = imageUri != null, + replaceIcon = + when (selectedImageOptions) { + ImageOptions.GALLERY -> R.drawable.ic_photo_library + ImageOptions.CAMERA -> R.drawable.ic_camera + ImageOptions.DRAWING -> R.drawable.ic_draw_filled + }, + onReplace = ::replaceImage, + onCrop = { viewModel.saveMultimediaForRevert( imagePath = viewModel.currentMultimediaPath.value, imageUri = viewModel.currentMultimediaUri.value, ) - dispatchCamera() - } - - ImageOptions.DRAWING -> { - openDrawingCanvas() - } - } - true + requestCrop() + }, + ) } + } + } + } - else -> false + private fun replaceImage() { + when (selectedImageOptions) { + ImageOptions.GALLERY -> openGallery() + ImageOptions.CAMERA -> { + viewModel.saveMultimediaForRevert( + imagePath = viewModel.currentMultimediaPath.value, + imageUri = viewModel.currentMultimediaUri.value, + ) + dispatchCamera() } + ImageOptions.DRAWING -> openDrawingCanvas() } } @@ -264,11 +256,10 @@ class MultimediaImageFragment : savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - setupMenu(multimediaMenu) + setupImageActions() setupWebView() handleImageUri() - setupDoneButton() } @NeedsTest("Verify the webview background color is transparent and matches the app theme") @@ -325,19 +316,17 @@ class MultimediaImageFragment : } } - private fun setupDoneButton() { - binding.actionDone.setOnClickListener { - Timber.d("MultimediaImageFragment:: Done button pressed") - if (viewModel.selectedMediaFileSize == 0L) { - Timber.d("Image length is not valid") - return@setOnClickListener - } - if (viewModel.selectedMediaFileSize > IMAGE_LIMIT) { - showLargeFileCropDialog(viewModel.selectedMediaFileSize) - return@setOnClickListener - } - finishAddingImage() + internal fun onDone() { + Timber.d("MultimediaImageFragment:: Done button pressed") + if (viewModel.selectedMediaFileSize == 0L) { + Timber.d("Image length is not valid") + return + } + if (viewModel.selectedMediaFileSize > IMAGE_LIMIT) { + showLargeFileCropDialog(viewModel.selectedMediaFileSize) + return } + finishAddingImage() } private fun finishAddingImage() { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaMenuProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaMenuProvider.kt index 70acc06b3e91..6aa77f3e2f43 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaMenuProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaMenuProvider.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.multimedia diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaResult.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaResult.kt index 6e957e0bb101..c1c366f06e48 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaResult.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaResult.kt @@ -1,19 +1,5 @@ -/* - * Copyright (c) 2026 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) 2026 Ashish Yadav package com.ichi2.anki.multimedia diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaUtils.kt index e617c2dad75e..5f8bd5f9c956 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaUtils.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.multimedia diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaViewModel.kt index 9a04a5d1d4d4..9843976d9d34 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaViewModel.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.multimedia diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/SoundUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/SoundUtils.kt index 927d45ecb759..797978d7c558 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/SoundUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/SoundUtils.kt @@ -1,19 +1,6 @@ -/* - * Copyright (c) 2009 Edu Zamora - * Copyright (c) 2014 Timothy rae - * - * 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) 2009 Edu Zamora +// SPDX-FileCopyrightText: Copyright (c) 2014 Timothy rae package com.ichi2.anki.multimedia diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioRecordingController.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioRecordingController.kt index b124a9c29679..23c58ae47e9c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioRecordingController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioRecordingController.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2023 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) 2023 Ashish Yadav + package com.ichi2.anki.multimedia.audio import android.app.Activity diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioWaveform.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioWaveform.kt index 92a709d81eb3..4a37e2fd674c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioWaveform.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioWaveform.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2023 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) 2023 Ashish Yadav + package com.ichi2.anki.multimedia.audio import android.content.Context diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/IMultimediaEditableNote.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/IMultimediaEditableNote.kt index c1b6556c348c..bf37e79fc4e0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/IMultimediaEditableNote.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/IMultimediaEditableNote.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.anki.multimediacard diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioField.kt index 9547bcf9c27b..a84df37cf5ea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioField.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.anki.multimediacard.fields diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioRecordingField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioRecordingField.kt index d6bdaa6e9011..e197920fc290 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioRecordingField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioRecordingField.kt @@ -1,16 +1,5 @@ -/* - * 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 + package com.ichi2.anki.multimediacard.fields /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/EFieldType.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/EFieldType.kt index f28dc5516202..15ea12e95fb5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/EFieldType.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/EFieldType.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.anki.multimediacard.fields diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/FieldBase.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/FieldBase.kt index 9c7d559420a8..2c4970d550fa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/FieldBase.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/FieldBase.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.anki.multimediacard.fields diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/IField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/IField.kt index 4fd167a884bc..7dcdb300dbdd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/IField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/IField.kt @@ -1,21 +1,9 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda + package com.ichi2.anki.multimediacard.fields import com.ichi2.anki.libanki.Collection diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/ImageField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/ImageField.kt index d3a3fc5645d0..67367ffaf4e9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/ImageField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/ImageField.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.anki.multimediacard.fields diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/MediaClipField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/MediaClipField.kt index c2996bb7f06c..064324c6c41a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/MediaClipField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/MediaClipField.kt @@ -1,16 +1,5 @@ -/* - * 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 + package com.ichi2.anki.multimediacard.fields /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/TextField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/TextField.kt index dad74b1e6374..301c148af9f1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/TextField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/TextField.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.anki.multimediacard.fields diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/impl/MultimediaEditableNote.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/impl/MultimediaEditableNote.kt index 76ba4d506010..1f01836341f0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/impl/MultimediaEditableNote.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/impl/MultimediaEditableNote.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.anki.multimediacard.impl diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt new file mode 100644 index 000000000000..3618819e56f2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.work.WorkManager +import androidx.work.await +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.backupLock +import com.ichi2.anki.common.destinations.LauncherDestination +import com.ichi2.anki.toIntent +import com.ichi2.anki.worker.UniqueWorkNames +import com.ichi2.anki.worker.mediaSyncLock +import com.ichi2.anki.worker.syncLock +import com.jakewharton.processphoenix.ProcessPhoenix +import kotlinx.coroutines.sync.Mutex +import timber.log.Timber +import java.io.File + +/** + * Restarts the app once background work has finished and the collection is closed. + * + * Sync work is cancelled once the sync lock is held, since it carries the current + * profile's AnkiWeb key. The work locks stay held until ProcessPhoenix kills this + * process, so nothing can start and reopen the collection in the meantime. + */ +suspend fun safeRestartApp(context: Context) { + Timber.i("Restarting the app") + syncLock.holdUnlessThrows { + WorkManager.getInstance(context).run { + cancelUniqueWork(UniqueWorkNames.SYNC).await() + cancelUniqueWork(UniqueWorkNames.SYNC_MEDIA).await() + } + mediaSyncLock.holdUnlessThrows { + backupLock.holdUnlessThrows { + CollectionManager.ensureClosed() + ProcessPhoenix.triggerRebirth(context, LauncherDestination.toIntent(context)) + } + } + } +} + +/** Holds this lock while [block] runs, and keeps holding it unless [block] throws. */ +private suspend inline fun Mutex.holdUnlessThrows(block: () -> Unit) { + lock() + var returned = false + try { + block() + returned = true + } finally { + if (!returned) unlock() + } +} + +/** True in the short-lived ProcessPhoenix process, which only relaunches the app. */ +fun isPhoenixProcess(): Boolean = isPhoenixProcessName(currentProcessName()) + +@VisibleForTesting +internal fun isPhoenixProcessName(name: String?): Boolean = name?.endsWith(":phoenix") == true + +private fun currentProcessName(): String? = runCatching { File("/proc/self/cmdline").readText().trim { it <= ' ' } }.getOrNull() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileContextWrapper.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileContextWrapper.kt index 06a0b1cc75c5..ae61b9dd6de4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileContextWrapper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileContextWrapper.kt @@ -1,19 +1,5 @@ -/* - * Copyright (c) 2025 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) 2025 Ashish Yadav package com.ichi2.anki.multiprofile diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileId.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileId.kt index 4e0402683ea7..fb595e5ebbd1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileId.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileId.kt @@ -1,19 +1,5 @@ -/* - * Copyright (c) 2025 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) 2025 Ashish Yadav package com.ichi2.anki.multiprofile diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index ba76d37ddebe..683525a8d8bc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -145,7 +145,8 @@ class ProfileManager private constructor( context(_: ProfileSwitchContext) fun switchActiveProfile(newProfileId: ProfileId) { Timber.i("Switching profile to ID: $newProfileId") - profileRegistry.setLastActiveProfileId(newProfileId) + // commit, not apply: the app restarts straight after a switch, and a pending apply() would be lost + profileRegistry.setLastActiveProfileId(newProfileId, commit = true) } private fun loadProfileData(profileId: ProfileId) { @@ -580,8 +581,11 @@ class ProfileManager private constructor( return id?.let { ProfileId(it) } } - fun setLastActiveProfileId(id: ProfileId) { - globalPrefs.edit { putString(KEY_LAST_ACTIVE_PROFILE_ID, id.value) } + fun setLastActiveProfileId( + id: ProfileId, + commit: Boolean = false, + ) { + globalPrefs.edit(commit = commit) { putString(KEY_LAST_ACTIVE_PROFILE_ID, id.value) } } fun getWebViewProfileId(): ProfileId? { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt index 649030bb55fc..16f1f7865f2f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt @@ -1,19 +1,5 @@ -/* - * Copyright (c) 2026 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) 2026 Ashish Yadav package com.ichi2.anki.multiprofile diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileRestrictedDirectory.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileRestrictedDirectory.kt index ef3abae29915..502c930034fb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileRestrictedDirectory.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileRestrictedDirectory.kt @@ -1,19 +1,5 @@ -/* - * Copyright (c) 2026 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) 2026 Ashish Yadav package com.ichi2.anki.multiprofile diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt new file mode 100644 index 000000000000..d3dfee316827 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.backupLock +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.BlockReason +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.SafetyCheck +import com.ichi2.anki.worker.mediaSyncLock +import com.ichi2.anki.worker.syncLock +import kotlinx.coroutines.sync.Mutex + +/** + * Blocks a switch while the collection is syncing in the background. + * + * Only the background sync needs this: a foreground sync holds a modal progress + * dialog, so the user cannot reach the switch until it finishes. + */ +fun syncCheck() = lockCheck(syncLock, BlockReason.SYNC_IN_PROGRESS) + +/** Blocks a switch while media is syncing. */ +fun mediaSyncCheck() = lockCheck(mediaSyncLock, BlockReason.MEDIA_SYNC_IN_PROGRESS) + +/** Blocks a switch while a background backup is running. */ +fun backupCheck() = lockCheck(backupLock, BlockReason.BACKUP_IN_PROGRESS) + +/** + * Reads a lock that the work holds only while it runs, so work that is queued but + * waiting, such as a sync waiting for the network, does not block the switch: + * there is nothing to interrupt yet, and [safeRestartApp] holds the same lock, so the + * work cannot start part way through the restart. + * + * [safeRestartApp] waits for work that is still running, so a check exists to tell the + * user why the switch cannot happen yet rather than for safety. + */ +private fun lockCheck( + lock: Mutex, + reason: BlockReason, +) = SafetyCheck { reason.takeIf { lock.isLocked } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt index 85adb854621a..3f7c66b16f75 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt @@ -1,19 +1,5 @@ -/* - * Copyright (c) 2026 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) 2026 Ashish Yadav package com.ichi2.anki.multiprofile @@ -43,8 +29,12 @@ class ProfileSwitchGuard( ) : Result() } + // TODO: COLLECTION_BUSY has no check yet: CollectionManager serialises on a + // private queue it does not expose. Closing the collection already waits for a + // running operation, so this is about warning the user rather than about safety. enum class BlockReason { BACKUP_IN_PROGRESS, + SYNC_IN_PROGRESS, MEDIA_SYNC_IN_PROGRESS, COLLECTION_BUSY, } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt new file mode 100644 index 000000000000..8b1db2d55ee7 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.noteeditor + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.Player +import com.ichi2.anki.common.android.appContext +import com.ichi2.anki.recorder.AudioRecorder +import com.ichi2.anki.recorder.PlayerState +import com.ichi2.anki.recorder.RecorderState +import com.ichi2.anki.recorder.VoicePlayer +import com.ichi2.anki.recorder.VoiceRecorder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import java.io.File +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class AudioRecordingViewModel( + audioRecorder: AudioRecorder = AudioRecorder(appContext), + createPlayer: () -> Player = { VoicePlayer.createExoPlayer(appContext) }, + recordingsDirectory: File = VoiceRecorder.recordingsDirectory(appContext), +) : ViewModel() { + private val recorder = VoiceRecorder(audioRecorder, recordingsDirectory, viewModelScope) + private val player = VoicePlayer(viewModelScope, createPlayer) + + val recorderState: StateFlow = recorder.state + val elapsed: StateFlow = recorder.elapsed + val amplitudes: SharedFlow = recorder.amplitudes + val playerState: StateFlow = player.state + val playbackPosition: StateFlow = player.position + + val failure: StateFlow + field = MutableStateFlow(null) + + private val isRecordingOrPaused: Boolean + get() = recorderState.value == RecorderState.Recording || recorderState.value == RecorderState.Paused + + init { + addCloseable { + player.close() + recorder.close() + } + player.state + .filter { it == PlayerState.Failed } + .onEach { failure.value = Failure.PLAYBACK } + .launchIn(viewModelScope) + } + + fun startRecording() { + if (recorderState.value != RecorderState.Idle) return ignore("start") + player.unload() + if (!recorder.start()) failure.value = Failure.RECORDING + } + + fun pauseRecording() { + if (recorderState.value != RecorderState.Recording) return ignore("pause") + if (!recorder.pause()) failure.value = Failure.RECORDING + } + + fun resumeRecording() { + if (recorderState.value != RecorderState.Paused) return ignore("resume") + if (!recorder.resume()) failure.value = Failure.RECORDING + } + + fun stopRecording() { + if (!isRecordingOrPaused) return ignore("stop") + if (!recorder.stop()) failure.value = Failure.RECORDING + } + + fun discard() { + player.unload() + recorder.discard() + } + + fun play() { + val file = (recorderState.value as? RecorderState.Recorded)?.file ?: return ignore("play") + val state = player.state.value + if (state == PlayerState.Empty || state == PlayerState.Failed) { + player.load(file) + } + player.play() + } + + fun pausePlayback() { + player.pause() + } + + fun skipBack() { + player.seekBy(-SKIP_INTERVAL) + } + + fun skipForward() { + player.seekBy(SKIP_INTERVAL) + } + + fun interrupt() { + if (isRecordingOrPaused) stopRecording() + player.unload() + } + + fun consumeRecording(block: (File) -> Unit) { + if (recorderState.value !is RecorderState.Recorded) return ignore("consume") + player.unload() + if (!recorder.consumeRecording(block)) failure.value = Failure.RECORDING + } + + fun failureShown() { + failure.value = null + } + + private fun ignore(command: String) { + Timber.w("Ignoring %s while %s", command, recorderState.value) + } + + enum class Failure { + RECORDING, + PLAYBACK, + } + + companion object { + private val SKIP_INTERVAL = 5.seconds + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorFragmentDelegate.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorFragmentDelegate.kt index 1d6cfcf3c704..294e2e125b83 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorFragmentDelegate.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorFragmentDelegate.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2025 Hari Srinivasan - * - * 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) 2025 Hari Srinivasan package com.ichi2.anki.noteeditor diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/AddNewNotesType.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/AddNewNotesType.kt index e982ea66724f..e684bfbc8d16 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/AddNewNotesType.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/AddNewNotesType.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2024 Neel Doshi - * - * 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 Neel Doshi + package com.ichi2.anki.notetype import android.view.LayoutInflater diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageWebViewClient.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageWebViewClient.kt index f7153e2cfdfc..c5642b5115ea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageWebViewClient.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageWebViewClient.kt @@ -19,6 +19,7 @@ import com.ichi2.utils.toRGBHex import timber.log.Timber import java.io.ByteArrayInputStream import java.io.IOException +import java.io.InputStream /** * Base WebViewClient to be used on [PageFragment] @@ -54,7 +55,7 @@ open class PageWebViewClient : SafeWebViewClient() { ): Boolean { val url = request?.url ?: return true if (isInternalUrl(url)) { - return !isSvelteKitPage(url.path.orEmpty().removePrefix("/")) + return SvelteKitPage.fromPath(url.path.orEmpty()) == null } if (request.isForMainFrame && url.scheme in listOf("http", "https")) { view?.context?.openUrl(url) @@ -72,10 +73,11 @@ open class PageWebViewClient : SafeWebViewClient() { return WebResourceResponse("image/x-icon", null, ByteArrayInputStream(byteArrayOf())) } + val page = SvelteKitPage.fromPath(path) val assetPath = if (path.startsWith("/_app/")) { "backend/sveltekit/app/${path.substring(6)}" - } else if (isSvelteKitPage(path.removePrefix("/"))) { + } else if (page != null) { "backend/sveltekit/index.html" } else { return null @@ -83,7 +85,14 @@ open class PageWebViewClient : SafeWebViewClient() { try { val mimeType = guessMimeType(assetPath) - val inputStream = view.context.assets.open(assetPath) + val inputStream = + view.context.assets.open(assetPath).let { asset -> + if (page?.disableCsp == true) { + removeBundledCsp(asset) + } else { + asset + } + } val response = WebResourceResponse(mimeType, null, inputStream) if ("immutable" in path) { response.responseHeaders = mapOf("Cache-Control" to "max-age=31536000") @@ -135,21 +144,13 @@ open class PageWebViewClient : SafeWebViewClient() { } } -fun isSvelteKitPage(path: String): Boolean { - val pageName = path.substringBefore("/") - return when (pageName) { - "graphs", - "congrats", - "card-info", - "change-notetype", - "deck-options", - "import-anki-package", - "import-csv", - "import-page", - "image-occlusion", - -> true - else -> false - } +private val SVELTEKIT_CSP_META = Regex("""""") + +/** Reads and closes [asset], returning the HTML without its bundled CSP meta tag. */ +private fun removeBundledCsp(asset: InputStream): InputStream { + val html = asset.bufferedReader().use { reader -> reader.readText() } + val htmlWithoutCsp = html.replace(SVELTEKIT_CSP_META, "") + return htmlWithoutCsp.byteInputStream() } fun WebView.evaluateAfterDOMContentLoaded( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PostRequestHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PostRequestHandler.kt index 1b07de5f91f0..ff4dc31a97af 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PostRequestHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PostRequestHandler.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import timber.log.Timber +import kotlin.time.Duration.Companion.seconds interface PostRequestHandler { suspend fun handlePostRequest( @@ -216,7 +217,7 @@ suspend fun FragmentActivity?.handleUiPostRequest( undoableOp { OpChanges.parseFrom(data) } launchCatchingTask { // Allow time for toast message to appear before closing editor - delay(1000) + delay(1.seconds) setResult(Activity.RESULT_OK) finish() } @@ -226,7 +227,7 @@ suspend fun FragmentActivity?.handleUiPostRequest( undoableOp { OpChanges.parseFrom(data) } launchCatchingTask { // Allow time for toast message to appear before closing editor - delay(1000) + delay(1.seconds) setResult(NoteEditorFragment.RESULT_UPDATED_IO_NOTE) finish() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/SvelteKitPage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/SvelteKitPage.kt new file mode 100644 index 000000000000..eb14c1345220 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/SvelteKitPage.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.pages + +/** + * Bundled SvelteKit routes exposed by AnkiDroid. + * + * CSP is enabled by default; pages may explicitly disable it for compatibility. + */ +internal enum class SvelteKitPage( + val route: String, + val disableCsp: Boolean = false, +) { + GRAPHS("graphs"), + + // #22334: `javascript:bridgeCommand` is used by 'Custom Study' and 'Unbury' + CONGRATS("congrats", disableCsp = true), + CARD_INFO("card-info"), + CHANGE_NOTETYPE("change-notetype"), + DECK_OPTIONS("deck-options"), + IMPORT_ANKI_PACKAGE("import-anki-package"), + IMPORT_CSV("import-csv"), + IMPORT_PAGE("import-page"), + IMAGE_OCCLUSION("image-occlusion"), + ; + + companion object { + /** Matches the first segment of a URL path, allowing an optional leading slash. */ + fun fromPath(path: String): SvelteKitPage? { + val route = path.removePrefix("/").substringBefore("/") + return entries.find { it.route == route } + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/GeneralSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/GeneralSettingsFragment.kt index 4b53b4c28ee1..9626e8c022a0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/GeneralSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/GeneralSettingsFragment.kt @@ -6,7 +6,6 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.preference.ListPreference import androidx.preference.SwitchPreferenceCompat -import anki.config.ConfigKey import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol @@ -15,6 +14,7 @@ import com.ichi2.anki.R import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.utils.ext.AddingDefaultsMode import com.ichi2.anki.common.utils.ext.addingDefaultsMode +import com.ichi2.anki.common.utils.ext.pasteImagesAsPng import com.ichi2.anki.contextmenu.AnkiCardContextMenu import com.ichi2.anki.contextmenu.CardBrowserContextMenu import com.ichi2.anki.launchCatchingTask @@ -46,9 +46,9 @@ class GeneralSettingsFragment : SettingsFragment() { // whether to convert clipboard uri to png format or not. requirePreference(R.string.paste_png_key).apply { title = TR.preferencesPasteClipboardImagesAsPng() - launchCatchingTask { isChecked = withCol { config.getBool(ConfigKey.Bool.PASTE_IMAGES_AS_PNG) } } + launchCatchingTask { isChecked = withCol { config.pasteImagesAsPng } } setOnPreferenceChangeListener { newValue -> - launchCatchingTask { withCol { config.setBool(ConfigKey.Bool.PASTE_IMAGES_AS_PNG, newValue) } } + launchCatchingTask { withCol { config.pasteImagesAsPng = newValue } } } } // Error reporting mode diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragment.kt index 0e5685d5c5eb..ece7653126f5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragment.kt @@ -13,7 +13,11 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.CommonString +import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.compose.theme.AnkiDroidTheme +import timber.log.Timber /** * Lets the user switch between profiles. @@ -22,14 +26,24 @@ import com.ichi2.compose.theme.AnkiDroidTheme * preference_headers.xml launches it by class name. */ class SwitchProfilesFragment : Fragment() { - private val viewModel: SwitchProfilesViewModel by viewModels() - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { + ): View { + val profileManager = AnkiDroidApp.instance.profileManager + if (profileManager == null) { + Timber.w("Profile environment unavailable, closing the switch profile screen") + showThemedToast(requireContext(), CommonString.something_wrong, false) + if (parentFragmentManager.backStackEntryCount > 0) { + parentFragmentManager.popBackStack() + } else { + requireActivity().finish() + } + return View(requireContext()) + } + val viewModel: SwitchProfilesViewModel by viewModels { SwitchProfilesViewModel.factory(profileManager) } + return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { AnkiDroidTheme { @@ -48,4 +62,5 @@ class SwitchProfilesFragment : Fragment() { } } } + } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/profiles/SwitchProfilesViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/profiles/SwitchProfilesViewModel.kt index b11c485c39d2..681f1282de5a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/profiles/SwitchProfilesViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/profiles/SwitchProfilesViewModel.kt @@ -4,6 +4,10 @@ package com.ichi2.anki.preferences.profiles import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.ichi2.anki.multiprofile.ProfileManager import com.ichi2.anki.multiprofile.ProfileName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,11 +17,12 @@ import timber.log.Timber * State holder for [SwitchProfilesFragment]. Keeps the dialog visibility out * of the view layer so it survives configuration changes. */ -class SwitchProfilesViewModel : ViewModel() { +class SwitchProfilesViewModel( + private val profileManager: ProfileManager, +) : ViewModel() { /** Profiles shown in the list. */ - // TODO: load from ProfileManager.getAllProfiles once ProfileManager is wired into the app val profiles: StateFlow> - field = MutableStateFlow>(emptyList()) + field = MutableStateFlow(profileManager.profileItems()) val isAddProfileDialogVisible: StateFlow field = MutableStateFlow(false) @@ -34,8 +39,7 @@ class SwitchProfilesViewModel : ViewModel() { fun addProfile(name: ProfileName) { isAddProfileDialogVisible.value = false Timber.i("Add profile confirmed (%d chars)", name.value.length) - // TODO: handle profile creation via ProfileManager.createNewProfile once - // ProfileManager is wired into the app + // TODO: handle profile creation via ProfileManager.createNewProfile } fun editProfile(profile: ProfileItem) { @@ -47,4 +51,27 @@ class SwitchProfilesViewModel : ViewModel() { Timber.i("Delete profile requested: %s", profile.id) // TODO: implement profile deletion via ProfileManager } + + /** Reloads the list from the registry. */ + fun refresh() { + profiles.value = profileManager.profileItems() + } + + companion object { + fun factory(profileManager: ProfileManager): ViewModelProvider.Factory = + viewModelFactory { + initializer { + SwitchProfilesViewModel(profileManager) + } + } + + /** + * The registry is backed by SharedPreferences, which has no defined order, + * so sort by name to keep the list stable across launches. + */ + private fun ProfileManager.profileItems(): List = + getAllProfiles() + .map { (id, metadata) -> ProfileItem(id = id, name = metadata.displayName.value) } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt index 0a7935847bbd..d19849561f62 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt @@ -57,7 +57,7 @@ enum class ViewerAction( DECK_OPTIONS(R.id.action_deck_options, R.drawable.ic_tune_white, DISABLED), CARD_INFO(R.id.action_card_info, R.drawable.ic_dialog_info, DISABLED), PREVIOUS_CARD_INFO(R.id.action_previous_card_info, R.drawable.ic_outline_info_24, DISABLED), - ADD_NOTE(R.id.action_add_note, R.drawable.ic_add, DISABLED), + ADD_NOTE(R.id.action_add_note, CommonR.drawable.ic_add, DISABLED), TAG(R.id.action_edit_tags, R.drawable.ic_tag, DISABLED), RESCHEDULE_NOTE(R.id.action_set_due_date, R.drawable.ic_reschedule, DISABLED), RESET_PROGRESS(R.id.action_reset_progress, R.drawable.ic_backup_restore, DISABLED), @@ -119,7 +119,7 @@ enum class ViewerAction( private val defaultBindings: List get() = when (this) { UNDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ctrl())) - REDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ModifierKeys(shift = true, ctrl = true, alt = false))) + REDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ModifierKeys(shift = true, ctrl = true))) MARK -> listOf(unicode('*')) EDIT -> listOf(keycode(KeyEvent.KEYCODE_E)) ADD_NOTE -> listOf(keycode(KeyEvent.KEYCODE_A)) @@ -135,8 +135,8 @@ enum class ViewerAction( BROWSE -> listOf(keycode(KeyEvent.KEYCODE_B)) STATISTICS -> listOf(keycode(KeyEvent.KEYCODE_T)) PLAY_MEDIA -> listOf(keycode(KeyEvent.KEYCODE_R)) - PREVIOUS_CARD_INFO -> listOf(keycode(KeyEvent.KEYCODE_I, ModifierKeys(shift = false, ctrl = true, alt = true))) - RESET_PROGRESS -> listOf(keycode(KeyEvent.KEYCODE_N, ModifierKeys(ctrl = true, alt = true, shift = false))) + PREVIOUS_CARD_INFO -> listOf(keycode(KeyEvent.KEYCODE_I, ModifierKeys(ctrl = true, alt = true))) + RESET_PROGRESS -> listOf(keycode(KeyEvent.KEYCODE_N, ModifierKeys(ctrl = true, alt = true))) TOGGLE_FLAG_RED -> listOf( keycode(KeyEvent.KEYCODE_1, ctrl()), diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt index 805c6fc4a939..b6b381339832 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt @@ -19,6 +19,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.CallSuper import androidx.annotation.LayoutRes import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.ThemeUtils import androidx.core.app.ActivityCompat import androidx.core.net.toUri import androidx.core.view.WindowInsetsCompat @@ -188,6 +189,7 @@ abstract class CardViewerFragment( with(webViewLayout) { setWebViewClient(onCreateWebViewClient(savedInstanceState)) setWebChromeClient(onCreateWebChromeClient()) + setBackgroundColor(ThemeUtils.getThemeAttrColor(requireContext(), android.R.attr.colorBackground)) scrollBars = View.SCROLLBARS_OUTSIDE_OVERLAY setAcceptThirdPartyCookies(true) with(settings) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt index 3c39654ec4db..2b46b8fd5f6b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt @@ -16,11 +16,8 @@ import androidx.core.view.ViewCompat import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.fragment.app.viewModels -import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory import com.google.android.material.slider.Slider import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString @@ -40,9 +37,11 @@ import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.ext.collectIn -import com.ichi2.anki.utils.ext.require +import com.ichi2.anki.utils.ext.getIntOrNull +import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.anki.utils.ext.setIconRes import com.ichi2.anki.utils.ext.sharedPrefs +import com.ichi2.anki.utils.savedStateViewModelFactory import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.utils.performClickIfEnabled import dev.androidbroadcast.vbpd.viewBinding @@ -58,19 +57,17 @@ class PreviewerFragment : DispatchKeyEventListener, BindingProcessor { override val viewModel: PreviewerViewModel by viewModels { - viewModelFactory { - initializer { - val handle = createSavedStateHandle() - // Read before constructing the ViewModel, which immediately loads the first card. - val ids = - try { - handle.require(CARD_IDS_FILE_ARG).getIds() - } catch (e: IOException) { - throw UnavailableSelectionException(e) - } - if (ids.isEmpty()) throw UnavailableSelectionException() - PreviewerViewModel(handle, ids) - } + savedStateViewModelFactory { handle -> + val arguments = requireArguments() + // Read before constructing the ViewModel, which immediately loads the first card. + val ids = + try { + arguments.requireParcelable(CARD_IDS_FILE_ARG).getIds() + } catch (e: IOException) { + throw UnavailableSelectionException(e) + } + if (ids.isEmpty()) throw UnavailableSelectionException() + PreviewerViewModel(handle, ids, requireNotNull(arguments.getIntOrNull(CURRENT_INDEX_ARG))) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt index 2f47242e4507..1665e4f862a3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt @@ -3,7 +3,6 @@ package com.ichi2.anki.previewer import androidx.annotation.VisibleForTesting -import androidx.lifecycle.SavedStateHandle import anki.collection.OpChanges import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.Flag @@ -19,8 +18,8 @@ import com.ichi2.anki.pages.AnkiServer import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.servicelayer.MARKED_TAG import com.ichi2.anki.servicelayer.NoteService +import com.ichi2.anki.utils.ViewModelSavedStateHandle import com.ichi2.anki.utils.ext.flag -import com.ichi2.anki.utils.ext.require import com.ichi2.anki.utils.ext.setUserFlagForCards import kotlinx.coroutines.Deferred import kotlinx.coroutines.flow.MutableStateFlow @@ -29,14 +28,15 @@ import kotlinx.coroutines.flow.update import timber.log.Timber class PreviewerViewModel( - savedStateHandle: SavedStateHandle, + savedStateHandle: ViewModelSavedStateHandle, @VisibleForTesting val selectedCardIds: List, + initialIndex: Int, ) : CardViewerViewModel(savedStateHandle), ChangeManager.Subscriber { val currentIndex = savedStateHandle.getMutableStateFlow( KEY_CURRENT_INDEX, - initialValue = savedStateHandle.require(PreviewerFragment.CURRENT_INDEX_ARG), + initialValue = initialIndex, ) val backSideOnly = savedStateHandle.getMutableStateFlow(KEY_BACKSIDE_ONLY, false) val isMarked = MutableStateFlow(false) @@ -55,7 +55,7 @@ class PreviewerViewModel( override var currentCard: Deferred = asyncIO { - withCol { getCard(selectedCardIds[savedStateHandle.require(PreviewerFragment.CURRENT_INDEX_ARG)]) } + withCol { getCard(selectedCardIds[currentIndex.value]) } } override val server = AnkiServer(this).also { it.start() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt index 2da6bdead833..3d20f930496b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt @@ -4,6 +4,8 @@ package com.ichi2.anki.progress import android.content.Context +import android.view.Window +import android.view.WindowManager import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -16,11 +18,12 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber +import java.util.WeakHashMap import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds /** - * Shows/dismisses a loading dialog driven by [viewModel]'s progress flow. + * Blocks touches and shows/dismisses a loading dialog driven by [viewModel]'s progress flow. * [delayMillis] defers the initial show so quick operations don't flash a dialog. * [resolveMessage] maps the ViewModel's message to the text shown in the dialog. */ @@ -29,8 +32,9 @@ fun AnkiActivity.observeProgress( delayMillis: Duration = 600.milliseconds, resolveMessage: Context.(M) -> String, ) { - var dialogVisible = - supportFragmentManager.findFragmentByTag(LoadingDialogFragment.TAG) != null + val dialogTag = "${LoadingDialogFragment.TAG}:${viewModel.javaClass.name}" + var dialogVisible = supportFragmentManager.findFragmentByTag(dialogTag) != null + var blockingTouches = false lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -44,15 +48,24 @@ fun AnkiActivity.observeProgress( pendingShow?.cancel() pendingShow = null dialogVisible = false - dismissLoadingDialog() + dismissLoadingDialog(dialogTag) + if (blockingTouches) { + blockingTouches = false + window.unblockTouches() + } } is ViewModelProgress.Active -> { + if (!blockingTouches) { + blockingTouches = true + window.blockTouches() + } if (dialogVisible) { showLoadingDialog( message = formatMessage(state, resolveMessage), cancellable = state.cancellable, + tag = dialogTag, ) - if (state.cancellable) wireCancelListener(viewModel) + if (state.cancellable) wireCancelListener(viewModel, dialogTag) } else if (pendingShow == null) { pendingShow = launch { @@ -63,8 +76,9 @@ fun AnkiActivity.observeProgress( showLoadingDialog( message = formatMessage(latest, resolveMessage), cancellable = latest.cancellable, + tag = dialogTag, ) - if (latest.cancellable) wireCancelListener(viewModel) + if (latest.cancellable) wireCancelListener(viewModel, dialogTag) } pendingShow = null } @@ -76,10 +90,31 @@ fun AnkiActivity.observeProgress( } } -private fun AnkiActivity.wireCancelListener(viewModel: HasProgress<*>) { +private val touchBlockCounts = WeakHashMap() + +internal fun Window.blockTouches() { + val count = touchBlockCounts[this] ?: 0 + if (count == 0) addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + touchBlockCounts[this] = count + 1 +} + +internal fun Window.unblockTouches() { + val count = touchBlockCounts[this] ?: return + if (count > 1) { + touchBlockCounts[this] = count - 1 + return + } + touchBlockCounts.remove(this) + clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) +} + +private fun AnkiActivity.wireCancelListener( + viewModel: HasProgress<*>, + dialogTag: String, +) { supportFragmentManager.executePendingTransactions() val fragment = - supportFragmentManager.findFragmentByTag(LoadingDialogFragment.TAG) + supportFragmentManager.findFragmentByTag(dialogTag) as? LoadingDialogFragment fragment?.dialog?.setOnCancelListener { viewModel.progressManager.requestCancel() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index d6113e638c43..fccd2f992dbe 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -52,6 +52,7 @@ import com.ichi2.utils.FileUtil.internalizeUri import com.ichi2.utils.Permissions.arePermissionsDefinedInManifest import net.ankiweb.rsdroid.BackendException import net.ankiweb.rsdroid.exceptions.BackendDeckIsFilteredException +import net.ankiweb.rsdroid.exceptions.BackendNotFoundException import org.json.JSONArray import org.json.JSONException import timber.log.Timber @@ -84,9 +85,6 @@ import java.io.IOException * */ -// TODO: Consider streaming Cursor results instead of materializing all rows -// to avoid potential OOM for large queries. -// Tracked in: https://github.com/ankidroid/Anki-Android/issues/20253 class CardContentProvider : ContentProvider() { private val applicationStartup = ContentProviderStartup() @@ -123,6 +121,48 @@ class CardContentProvider : ContentProvider() { */ private val defaultNoteProjectionDbAccess = FlashCardsContract.Note.DEFAULT_COLUMNS + private val cardColumns: Map Any?> = + mapOf( + FlashCardsContract.Card._ID to { card, _ -> card.id }, + FlashCardsContract.Card.NOTE_ID to { card, _ -> card.nid }, + FlashCardsContract.Card.CARD_ORD to { card, _ -> card.ord }, + FlashCardsContract.Card.CARD_NAME to { card, col -> cardName(card, col) }, + FlashCardsContract.Card.DECK_ID to { card, _ -> card.did }, + FlashCardsContract.Card.REPS to { card, _ -> card.reps }, + FlashCardsContract.Card.LAPSES to { card, _ -> card.lapses }, + FlashCardsContract.Card.TYPE to { card, _ -> card.type.code }, + FlashCardsContract.Card.ORIGINAL_DECK_ID to { card, _ -> card.oDid }, + FlashCardsContract.Card.QUESTION to { card, col -> card.renderOutput(col).questionWithFixedSoundTags() }, + FlashCardsContract.Card.ANSWER to { card, col -> card.renderOutput(col).answerWithFixedSoundTags() }, + FlashCardsContract.Card.QUESTION_SIMPLE to { card, col -> card.renderOutput(col).questionText }, + FlashCardsContract.Card.ANSWER_SIMPLE to { card, col -> card.renderOutput(col, false).answerText }, + FlashCardsContract.Card.ANSWER_PURE to { card, col -> card.pureAnswer(col) }, + FlashCardsContract.Card.RAW_QUEUE to { card, _ -> card.queue.code }, + FlashCardsContract.Card.RAW_DUE to { card, _ -> card.due }, + FlashCardsContract.Card.RAW_ORIGINAL_DUE to { card, _ -> card.oDue }, + FlashCardsContract.Card.INTERVAL to { card, _ -> card.ivl }, + FlashCardsContract.Card.RAW_SM2_FACTOR to { card, _ -> card.factor }, + FlashCardsContract.Card.RAW_LEFT to { card, _ -> card.left }, + FlashCardsContract.Card.ORIGINAL_POSITION to { card, _ -> card.originalPosition }, + FlashCardsContract.Card.RAW_CUSTOM_DATA to { card, _ -> card.customData }, + FlashCardsContract.Card.FSRS_STABILITY to { card, _ -> card.memoryStateStability }, + FlashCardsContract.Card.FSRS_DIFFICULTY to { card, _ -> card.memoryStateDifficulty }, + FlashCardsContract.Card.FSRS_DESIRED_RETENTION to { card, _ -> card.fsrsDesiredRetention }, + FlashCardsContract.Card.FSRS_DECAY to { card, _ -> card.decay }, + FlashCardsContract.Card.LAST_REVIEW_TIME_SECONDS to { card, _ -> card.lastReviewTimeSecs }, + FlashCardsContract.Card.FLAGS to { card, _ -> card.flags and 7 }, // return only 3 first bits + ) + + private fun cardName( + card: Card, + col: Collection, + ): String = + try { + card.template(col).name + } catch (je: JSONException) { + throw IllegalArgumentException("Card is using an invalid template", je) + } + private fun sanitizeNoteProjection(projection: Array?): Array { if (projection.isNullOrEmpty()) { return defaultNoteProjectionDbAccess @@ -433,6 +473,9 @@ class CardContentProvider : ContentProvider() { val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS val query = selection ?: "" + // validate the columns + columns.forEach { cardColumnAccessor(it) } + val cardIds = try { col.findCards(query) @@ -446,21 +489,28 @@ class CardContentProvider : ContentProvider() { // Fast path: Only if _id is requested val onlyRequestingId = columns.singleOrNull() == FlashCardsContract.Card._ID - if (onlyRequestingId) { - // Return IDs without fetching card objects - val rv = MatrixCursor(columns, cardIds.size) - for (cardId in cardIds) { - rv.newRow().add(cardId) - } - rv - } else { - // Get all requested fields - val rv = MatrixCursor(columns, cardIds.size) - for (cardId in cardIds) { - val card = col.getCard(cardId) - addCardToCursor(card, rv, col, columns) + LazyCursor(columns, cardIds.size) { position, row -> + val cardId = cardIds[position] + if (onlyRequestingId) { + row.newRow().add(cardId) + } else { + val currentCol = getColUnsafe() + val card = + try { + currentCol.getCard(cardId) + } catch (e: BackendNotFoundException) { + Timber.d(e, "card %d was deleted after the query returned", cardId) + null + } + if (card != null) { + addCardToCursor(card, row, currentCol, columns) + } else { + val rb = row.newRow() + for (column in columns) { + rb.add(if (column == FlashCardsContract.Card._ID) cardId else null) + } + } } - rv } } CARD_ID -> { @@ -1203,50 +1253,15 @@ class CardContentProvider : ContentProvider() { col: Collection, columns: Array, ) { - val cardName: String = - try { - currentCard.template(col).name - } catch (je: JSONException) { - throw IllegalArgumentException("Card is using an invalid template", je) - } - val question = currentCard.renderOutput(col).questionWithFixedSoundTags() - val answer = currentCard.renderOutput(col).answerWithFixedSoundTags() val rb = rv.newRow() for (column in columns) { - when (column) { - FlashCardsContract.Card._ID -> rb.add(currentCard.id) - FlashCardsContract.Card.NOTE_ID -> rb.add(currentCard.nid) - FlashCardsContract.Card.CARD_ORD -> rb.add(currentCard.ord) - FlashCardsContract.Card.CARD_NAME -> rb.add(cardName) - FlashCardsContract.Card.DECK_ID -> rb.add(currentCard.did) - FlashCardsContract.Card.REPS -> rb.add(currentCard.reps) - FlashCardsContract.Card.LAPSES -> rb.add(currentCard.lapses) - FlashCardsContract.Card.TYPE -> rb.add(currentCard.type.code) - FlashCardsContract.Card.ORIGINAL_DECK_ID -> rb.add(currentCard.oDid) - FlashCardsContract.Card.QUESTION -> rb.add(question) - FlashCardsContract.Card.ANSWER -> rb.add(answer) - FlashCardsContract.Card.QUESTION_SIMPLE -> rb.add(currentCard.renderOutput(col).questionText) - FlashCardsContract.Card.ANSWER_SIMPLE -> rb.add(currentCard.renderOutput(col, false).answerText) - FlashCardsContract.Card.ANSWER_PURE -> rb.add(currentCard.pureAnswer(col)) - FlashCardsContract.Card.RAW_QUEUE -> rb.add(currentCard.queue.code) - FlashCardsContract.Card.RAW_DUE -> rb.add(currentCard.due) - FlashCardsContract.Card.RAW_ORIGINAL_DUE -> rb.add(currentCard.oDue) - FlashCardsContract.Card.INTERVAL -> rb.add(currentCard.ivl) - FlashCardsContract.Card.RAW_SM2_FACTOR -> rb.add(currentCard.factor) - FlashCardsContract.Card.RAW_LEFT -> rb.add(currentCard.left) - FlashCardsContract.Card.ORIGINAL_POSITION -> rb.add(currentCard.originalPosition) - FlashCardsContract.Card.RAW_CUSTOM_DATA -> rb.add(currentCard.customData) - FlashCardsContract.Card.FSRS_STABILITY -> rb.add(currentCard.memoryStateStability) - FlashCardsContract.Card.FSRS_DIFFICULTY -> rb.add(currentCard.memoryStateDifficulty) - FlashCardsContract.Card.FSRS_DESIRED_RETENTION -> rb.add(currentCard.fsrsDesiredRetention) - FlashCardsContract.Card.FSRS_DECAY -> rb.add(currentCard.decay) - FlashCardsContract.Card.LAST_REVIEW_TIME_SECONDS -> rb.add(currentCard.lastReviewTimeSecs) - FlashCardsContract.Card.FLAGS -> rb.add(currentCard.flags and 7) // return only 3 first bits - else -> throw UnsupportedOperationException("Queue \"$column\" is unknown") - } + rb.add(cardColumnAccessor(column)(currentCard, col)) } } + private fun cardColumnAccessor(column: String): (Card, Collection) -> Any? = + cardColumns[column] ?: throw UnsupportedOperationException("Column \"$column\" is unknown") + private fun addReviewInfoToCursor( currentCard: Card, nextReviewTimesJson: JSONArray, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt new file mode 100644 index 000000000000..dd6e2090db40 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.provider + +import android.database.AbstractCursor +import android.database.MatrixCursor + +/** + * A cursor that loads rows on demand and retains at most one loaded row. + * + * Creating the cursor, reading its count, and moving between positions do not load any rows. + * Reading a column calls [loadRow], which must add exactly one row to the supplied [MatrixCursor]. + * Further reads at the same position reuse that row. Moving discards it, so reading it again after + * moving away calls [loadRow] again and may yield updated values. Android can copy rows into a + * cursor window without this cursor retaining all the results. + * + * For example, defer fetching card details until a result is read: + * ```kotlin + * val cursor = LazyCursor(arrayOf("_id", "reps"), cardIds.size) { position, row -> + * val card = col.getCardOrNull(cardIds[position]) + * row.newRow().add(card.id).add(card.reps) + * } + * ``` + */ +internal class LazyCursor( + private val columns: Array, + private val rowCount: Int, + loadRow: (Int, MatrixCursor) -> Unit, +) : AbstractCursor() { + private var loadRow: ((Int, MatrixCursor) -> Unit)? = loadRow + private var row: MatrixCursor? = null + + override fun getCount(): Int = rowCount + + override fun getColumnNames(): Array = columns + + override fun onMove( + oldPosition: Int, + newPosition: Int, + ): Boolean { + check(!isClosed) + row?.close() + row = null + return true + } + + private fun currentRow(): MatrixCursor { + check(!isClosed) + checkPosition() + row?.let { return it } + val next = MatrixCursor(columns, 1) + try { + loadRow!!(position, next) + check(next.count == 1) + next.moveToFirst() + row = next + } catch (e: Exception) { + next.close() + throw e + } + return next + } + + override fun getString(column: Int): String? = currentRow().getString(column) + + override fun getShort(column: Int): Short = currentRow().getShort(column) + + override fun getInt(column: Int): Int = currentRow().getInt(column) + + override fun getLong(column: Int): Long = currentRow().getLong(column) + + override fun getFloat(column: Int): Float = currentRow().getFloat(column) + + override fun getDouble(column: Int): Double = currentRow().getDouble(column) + + override fun getBlob(column: Int): ByteArray? = currentRow().getBlob(column) + + override fun getType(column: Int): Int = currentRow().getType(column) + + override fun isNull(column: Int): Boolean = currentRow().isNull(column) + + override fun close() { + row?.close() + row = null + loadRow = null + super.close() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioTimer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioTimer.kt index b9780b7b5289..3f56a9994d86 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioTimer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioTimer.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2023 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) 2023 Ashish Yadav package com.ichi2.anki.recorder diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoicePlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoicePlayer.kt new file mode 100644 index 000000000000..5d93f223b667 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoicePlayer.kt @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.recorder + +import android.content.Context +import android.net.Uri +import androidx.annotation.MainThread +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.Closeable +import java.io.File +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +sealed interface PlayerState { + data object Empty : PlayerState + + data object Loading : PlayerState + + data class Ready( + val duration: Duration, + val isPlaying: Boolean, + ) : PlayerState + + data object Failed : PlayerState +} + +@MainThread +class VoicePlayer( + private val scope: CoroutineScope, + private val createPlayer: () -> Player, +) : Closeable { + val state: StateFlow + field = MutableStateFlow(PlayerState.Empty) + + val position: StateFlow + field = MutableStateFlow(Duration.ZERO) + + private var player: Player? = null + private var ticker: Job? = null + private var released = false + + private val listener = + object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> onReady() + Player.STATE_ENDED -> onEnded() + } + } + + override fun onPlayWhenReadyChanged( + playWhenReady: Boolean, + reason: Int, + ) { + val ready = state.value as? PlayerState.Ready ?: return + setState(ready.copy(isPlaying = playWhenReady)) + } + + override fun onPlayerError(error: PlaybackException) { + if (state.value == PlayerState.Empty) return + Timber.w(error, "Could not play the recording") + setState(PlayerState.Failed) + } + } + + fun load(file: File) { + if (released) return + val player = obtainPlayer() + setState(PlayerState.Loading) + position.value = Duration.ZERO + player.pause() + player.setMediaItem(MediaItem.fromUri(Uri.fromFile(file))) + player.prepare() + } + + fun play() { + val player = activePlayer() ?: return + if (player.playbackState == Player.STATE_ENDED) { + seekTo(Duration.ZERO) + } + player.play() + } + + fun pause() { + activePlayer()?.pause() + } + + fun seekTo(position: Duration) { + val ready = state.value as? PlayerState.Ready ?: return + val player = player ?: return + val target = position.coerceIn(Duration.ZERO, ready.duration) + player.seekTo(target.inWholeMilliseconds) + this.position.value = target + } + + fun seekBy(offset: Duration) { + if (state.value !is PlayerState.Ready) return + val player = player ?: return + seekTo(player.currentPosition.milliseconds + offset) + } + + fun unload() { + setState(PlayerState.Empty) + position.value = Duration.ZERO + player?.run { + stop() + clearMediaItems() + } + } + + override fun close() { + if (released) return + released = true + setState(PlayerState.Empty) + player?.release() + player = null + } + + private fun obtainPlayer(): Player = + player ?: createPlayer().also { + it.addListener(listener) + player = it + } + + private fun activePlayer(): Player? { + val state = state.value + if (state != PlayerState.Loading && state !is PlayerState.Ready) return null + return player + } + + private fun onReady() { + val player = player ?: return + val duration = if (player.duration == C.TIME_UNSET) Duration.ZERO else player.duration.milliseconds + when (val current = state.value) { + PlayerState.Loading -> setState(PlayerState.Ready(duration, player.playWhenReady)) + is PlayerState.Ready -> setState(current.copy(duration = duration)) + PlayerState.Empty, PlayerState.Failed -> {} + } + } + + private fun onEnded() { + val ready = state.value as? PlayerState.Ready ?: return + player?.pause() + position.value = ready.duration + } + + private fun setState(value: PlayerState) { + state.value = value + if (value is PlayerState.Ready && value.isPlaying) { + if (ticker?.isActive == true) return + ticker = + scope.launch { + while (isActive) { + player?.let { position.value = it.currentPosition.milliseconds } + delay(TICK_INTERVAL) + } + } + } else { + ticker?.cancel() + ticker = null + } + } + + companion object { + private val TICK_INTERVAL = 16.milliseconds + + fun createExoPlayer(context: Context): Player = + ExoPlayer + .Builder(context.applicationContext) + .setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .build(), + true, + ).setHandleAudioBecomingNoisy(true) + .build() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt new file mode 100644 index 000000000000..3932c04a1537 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.recorder + +import android.content.Context +import androidx.annotation.MainThread +import com.ichi2.anki.common.time.TimeManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import timber.log.Timber +import java.io.Closeable +import java.io.File +import java.io.IOException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.TimeSource + +sealed interface RecorderState { + data object Idle : RecorderState + + data object Recording : RecorderState + + data object Paused : RecorderState + + data class Recorded( + val file: File, + ) : RecorderState +} + +@MainThread +class VoiceRecorder( + private val audioRecorder: AudioRecorder, + private val directory: File, + scope: CoroutineScope, + timeSource: TimeSource = TimeSource.Monotonic, +) : Closeable { + val state: StateFlow + field = MutableStateFlow(RecorderState.Idle) + + val elapsed: StateFlow + field = MutableStateFlow(Duration.ZERO) + + val amplitudes: SharedFlow + field = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private val timer = + AudioTimer( + scope = scope, + onTimerTick = { elapsed.value = it }, + onAudioTick = ::sampleAmplitude, + timeSource = timeSource, + ) + + private var recordingFile: File? = null + private var closed = false + + private val isRecordingOrPaused: Boolean + get() = state.value == RecorderState.Recording || state.value == RecorderState.Paused + + fun start(): Boolean { + if (closed) return false + discard() + val file = createRecordingFile() ?: return false + try { + audioRecorder.start(file) + } catch (e: RuntimeException) { + Timber.w(e, "Could not create the MediaRecorder") + } + if (!audioRecorder.isRecording) { + file.delete() + return false + } + recordingFile = file + state.value = RecorderState.Recording + timer.start() + return true + } + + fun pause(): Boolean { + if (state.value != RecorderState.Recording) return false + try { + audioRecorder.pause() + } catch (e: IllegalStateException) { + Timber.w(e, "Could not pause the recording") + return false + } + timer.pause() + state.value = RecorderState.Paused + return true + } + + fun resume(): Boolean { + if (state.value != RecorderState.Paused) return false + try { + audioRecorder.resume() + } catch (e: IllegalStateException) { + Timber.w(e, "Could not resume the recording") + return false + } + state.value = RecorderState.Recording + timer.start() + return true + } + + fun stop(): Boolean { + val file = recordingFile + if (file == null || !isRecordingOrPaused) return false + timer.stop() + audioRecorder.stop() + if (file.length() > 0) { + state.value = RecorderState.Recorded(file) + return true + } + Timber.w("The recording is missing or empty") + deleteRecording() + return false + } + + fun discard() { + if (isRecordingOrPaused) { + timer.stop() + audioRecorder.stop() + } + deleteRecording() + } + + /** + * Hands the finished recording to [block], which then owns the file: [start], [discard] and + * [close] no longer delete it. Returns false if there is no finished recording. + */ + fun consumeRecording(block: (File) -> Unit): Boolean { + val file = (state.value as? RecorderState.Recorded)?.file ?: return false + if (file.length() == 0L) { + Timber.w("The recorded file is missing or empty") + deleteRecording() + return false + } + recordingFile = null + state.value = RecorderState.Idle + block(file) + return true + } + + override fun close() { + if (closed) return + closed = true + discard() + audioRecorder.close() + } + + private fun createRecordingFile(): File? = + try { + directory.mkdirs() + File.createTempFile(FILE_PREFIX, FILE_SUFFIX, directory) + } catch (e: IOException) { + Timber.w(e, "Could not create a recording file") + null + } + + private fun sampleAmplitude() { + val amplitude = + try { + audioRecorder.getMaxAmplitude() + } catch (e: IllegalStateException) { + Timber.w(e, "Could not read the amplitude") + null + } + amplitudes.tryEmit(amplitude) + } + + private fun deleteRecording() { + val file = recordingFile + if (file != null && file.exists() && !file.delete()) { + Timber.w("Could not delete %s", file) + } + recordingFile = null + state.value = RecorderState.Idle + } + + companion object { + const val RECORDINGS_DIRECTORY = "recordings" + private const val FILE_PREFIX = "ankidroid_audiorec" + private const val FILE_SUFFIX = ".3gp" + private val RECORDING_RETENTION = 7.days + + fun recordingsDirectory(context: Context): File = File(context.noBackupFilesDir, RECORDINGS_DIRECTORY) + + fun removeExpiredRecordings( + directory: File, + now: Long = TimeManager.time.intTimeMS(), + ) { + val cutoff = now - RECORDING_RETENTION.inWholeMilliseconds + directory + .listFiles { _, name -> name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX) } + ?.forEach { file -> + val modified = file.lastModified() + if (file.isFile && modified > 0 && modified < cutoff && !file.delete()) { + Timber.w("Could not delete %s", file) + } + } + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt index 386c85a4ce12..d7cacd83ed28 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt @@ -136,9 +136,9 @@ sealed interface Binding { val isValid get() = true open class ModifierKeys internal constructor( - val shift: Boolean, - val ctrl: Boolean, - val alt: Boolean, + val shift: Boolean = false, + val ctrl: Boolean = false, + val alt: Boolean = false, ) { fun matches(event: KeyEvent): Boolean { // return false if Ctrl+1 is pressed and 1 is expected @@ -176,13 +176,13 @@ sealed interface Binding { override fun hashCode(): Int = Objects.hash(ctrl, alt, shiftMatches(true)) companion object { - fun none(): ModifierKeys = ModifierKeys(shift = false, ctrl = false, alt = false) + fun none(): ModifierKeys = ModifierKeys() - fun ctrl(): ModifierKeys = ModifierKeys(shift = false, ctrl = true, alt = false) + fun ctrl(): ModifierKeys = ModifierKeys(ctrl = true) - fun shift(): ModifierKeys = ModifierKeys(shift = true, ctrl = false, alt = false) + fun shift(): ModifierKeys = ModifierKeys(shift = true) - fun alt(): ModifierKeys = ModifierKeys(shift = false, ctrl = false, alt = true) + fun alt(): ModifierKeys = ModifierKeys(alt = true) /** * Parses a [ModifierKeys] from a string. @@ -200,7 +200,7 @@ sealed interface Binding { } /** Modifier keys which cannot be defined by a binding */ - class AppDefinedModifierKeys private constructor() : ModifierKeys(false, false, false) { + class AppDefinedModifierKeys private constructor() : ModifierKeys() { override fun shiftMatches(shiftPressed: Boolean): Boolean = true companion object { @@ -290,11 +290,6 @@ sealed interface Binding { return UnknownBinding } - fun unicode( - modifierKeys: ModifierKeys, - unicodeChar: Char, - ): Binding = unicode(unicodeChar, modifierKeys) - /** * Specifies a unicode binding from an unknown input device * See [AppDefinedModifierKeys] @@ -312,11 +307,6 @@ sealed interface Binding { modifiers: ModifierKeys = ModifierKeys.none(), ) = KeyCode(keyCode, modifiers) - fun keyCode( - modifiers: ModifierKeys, - keyCode: Int, - ) = KeyCode(keyCode, modifiers) - fun gesture(gesture: Gesture) = GestureInput(gesture) @VisibleForTesting diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerCustomFonts.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerCustomFonts.kt index c9db98a6c0a1..102290545953 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerCustomFonts.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerCustomFonts.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Flavio Lerda package com.ichi2.anki.reviewer diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index 265d8c6aa04c..e5a5c558642a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -39,7 +39,7 @@ import com.ichi2.anki.utils.ext.getParcelableCompat import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.utils.showDialogFragment -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.Permissions import com.ichi2.utils.customView import com.ichi2.utils.negativeButton @@ -125,7 +125,7 @@ class AddEditReminderDialog : DialogFragment() { setUpCardThresholdInput() setUpOnlyNotifyIfNoReviewsCheckbox() - dialog.window?.let { resizeWhenSoftInputShown(it) } + setDialogKeyboardResize(dialog) return dialog } @@ -319,8 +319,9 @@ class AddEditReminderDialog : DialogFragment() { val confirmationDialog = ConfirmationDialog() confirmationDialog.setArgs( - getString(CommonString.add_edit_reminder_delete_confirmation_title), - getString(CommonString.add_edit_reminder_delete_confirmation_message), + title = getString(CommonString.add_edit_reminder_delete_confirmation_title), + message = getString(CommonString.add_edit_reminder_delete_confirmation_message), + positiveButtonText = getString(CommonString.dialog_positive_delete), ) confirmationDialog.setConfirm { setFragmentResult( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminderAlarmManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminderAlarmManager.kt index daabb4115a8d..2dc141553aed 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminderAlarmManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminderAlarmManager.kt @@ -7,9 +7,9 @@ import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import androidx.core.app.PendingIntentCompat +import com.ichi2.anki.common.android.AlarmManagement import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.services.NotificationService -import com.ichi2.utils.AlarmManagement import timber.log.Timber import java.util.Calendar @@ -77,7 +77,7 @@ object ReviewReminderAlarmManager { * triggering [NotificationService.sendReviewReminderNotification], this method is called again to * schedule the next upcoming notification. If for some reason the next day's alarm fails to be set by * the current day's notification, we fall back to setting alarms whenever the application process is started: see - * [com.ichi2.anki.AnkiDroidApp]'s call to [AlarmManagement.scheduleAllNotifications]. + * [com.ichi2.anki.AnkiDroidApp]'s call to [com.ichi2.anki.scheduleAllNotifications]. * * If an old version of this review reminder with the same review reminder ID has already had * its notifications scheduled, this will merely update the existing notifications. If, however, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt index 8673fd41fb62..a3e71cbe688a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt @@ -17,6 +17,7 @@ import com.google.android.material.materialswitch.MaterialSwitch import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString import com.ichi2.anki.R +import com.ichi2.anki.common.utils.ext.setBitFlag import com.ichi2.anki.databinding.ItemScheduleRemindersBinding import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.ui.internationalization.sentenceCase @@ -131,12 +132,7 @@ class ScheduleRemindersAdapter( textView: TextView, setStrikethrough: Boolean, ) { - textView.paintFlags = - if (setStrikethrough) { - textView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - } else { - textView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } + textView.paintFlags = textView.paintFlags.setBitFlag(Paint.STRIKE_THRU_TEXT_FLAG, enabled = setStrikethrough) } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersDestination.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersDestination.kt index 763dd84af69d..53a49c726388 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersDestination.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersDestination.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2025 Eric Li - * - * 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) 2025 Eric Li package com.ichi2.anki.reviewreminders diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt index a91bed62c7af..4298b0897805 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt @@ -278,20 +278,20 @@ class SetDueDateDialog : AnalyticsDialogFragment() { Timber.d("Ignoring 'set due date' request: dialog is already open") return } - val dialog = newInstance(activity.externalCacheDir ?: activity.cacheDir, cardIds) + val dialog = newInstance(IdsFile.getDirectory(activity), cardIds) activity.showDialogFragment(dialog) } @VisibleForTesting @CheckResult fun newInstance( - cacheDir: File, + idsFileDirectory: File, cardIds: List, ): SetDueDateDialog = SetDueDateDialog().apply { arguments = Bundle().apply { - putParcelable(ARG_IDS_FILE, IdsFile(cacheDir, cardIds, "set-due-date")) + putParcelable(ARG_IDS_FILE, IdsFile(idsFileDirectory, cardIds, IdsFile.Purpose.SET_DUE_DATE)) } Timber.i("Showing 'set due date' dialog for %d cards", cardIds.size) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/DebugInfoService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/DebugInfoService.kt index b3d31d4e825a..3cfaae2301aa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/DebugInfoService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/DebugInfoService.kt @@ -7,6 +7,7 @@ import android.os.Build import com.ichi2.anki.BuildConfig import com.ichi2.anki.CollectionManager import com.ichi2.anki.common.crashreporting.CrashReportService +import com.ichi2.anki.common.utils.ext.fsrsEnabled import com.ichi2.utils.VersionUtils.pkgVersionName import com.ichi2.utils.getWebViewInfo import org.acra.util.Installation @@ -49,7 +50,7 @@ object DebugInfoService { */ suspend fun getFSRSStatus(): Boolean? = try { - CollectionManager.withOpenColOrNull { config.get("fsrs", false) } + CollectionManager.withOpenColOrNull { config.fsrsEnabled } } catch (e: Throwable) { // Error and Exception paths are the same, so catch Throwable Timber.w(e) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt index f560b811c116..39e39e7277db 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.anki.servicelayer diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt index 879aaec4c884..8b479484308b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt @@ -1,19 +1,7 @@ -/* - * Copyright (c) 2024 voczi - * Copyright (c) 2024 Mike Hardy - * - * 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 voczi +// SPDX-FileCopyrightText: Copyright (c) 2024 Mike Hardy + package com.ichi2.anki.servicelayer import androidx.annotation.VisibleForTesting diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt index 52775dfa80a4..9e31c80617a8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt @@ -19,12 +19,13 @@ import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.time.Time import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.utils.android.showThemedToast +import com.ichi2.anki.common.utils.ext.rolloverHour import com.ichi2.anki.libanki.Collection import com.ichi2.anki.preferences.PENDING_NOTIFICATIONS_ONLY import com.ichi2.anki.runGloballyWithTimeout +import com.ichi2.anki.scheduleAllNotifications import com.ichi2.anki.settings.Prefs import com.ichi2.anki.widget.RECURRING_WIDGETS -import com.ichi2.utils.AlarmManagement import com.ichi2.widget.DayRolloverAlarm import com.ichi2.widget.restoreRecurringAlarms import timber.log.Timber @@ -59,13 +60,13 @@ class BootService : AnkiBroadcastReceiver() { Timber.w("Boot Service did not execute - no permissions") return } - // 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 upon boot") runGloballyWithTimeout(SCHEDULE_NOTIFICATIONS_TIMEOUT) { - AlarmManagement.scheduleAllNotifications(context) + scheduleAllNotifications(context) } } else { // There are cases where the app is installed, and we have access, but nothing exist yet @@ -191,7 +192,7 @@ class BootService : AnkiBroadcastReceiver() { val sp = context.sharedPrefs() sp.getInt("dayOffset", defValue) } - 2 -> col.config.get("rollover") ?: defValue + 2 -> col.config.rolloverHour else -> { val sp = context.sharedPrefs() sp.getInt("dayOffset", defValue) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt index 206c1fa497ba..ff5683440a44 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -54,6 +54,11 @@ import com.ichi2.anki.common.android.R as CommonR * * This service can be triggered in one of two possible ways, depending on whether the notification * being fired is a recurring notification or a one-time snoozed notification. See [NotificationServiceAction]. + * + * TODO: Rename and reorganize the (handle/send/fire)ReviewReminderNotification functions for better readability and separation of concerns. + * These three verbs are way too similar. The "send" method should be split into "validate" and "hydrate". "send" sometimes aborts and does nothing. + * The distinction between "handle" and "send" should be made more clear. All uses of "fire" in the codebase, including in documentation, + * should be aligned to mean "trigger the OS display unconditionally." */ class NotificationService : AnkiBroadcastReceiver() { companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt index ec0a0fc52037..7f084abab01a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt @@ -113,25 +113,9 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { return true } - private val cookieManager: CookieManager by lazy { - CookieManager.getInstance() - } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val isLoggedInToAnkiWeb: Boolean - get() { - try { - // cookies are null after the user logs out, or if the site is first visited - val cookies = cookieManager.getCookie("https://ankiweb.net") ?: return false - // ankiweb currently (2024-09-25) sets two cookies: - // * `ankiweb`, which is base64-encoded JSON - // * `has_auth`, which is 1 - return cookies.contains("has_auth=1") - } catch (e: Exception) { - Timber.w(e, "Could not determine login status") - return false - } - } + get() = isLoggedInToAnkiWeb() override fun onReceivedHttpError( view: WebView?, @@ -165,33 +149,17 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { /** * Redirects the user to a login page * - * A message is shown informing the user they need to log in to download more decks - * - * If the user has not logged in **inside AnkiDroid** then the message provides - * the user with an action to sign up - * * The redirect is not performed if [redirectTimes] is 3 or more */ private fun redirectUserToSignUpOrLogin() { - // inform the user they need to log in as they've hit a rate limit - showSnackbar(CommonString.shared_decks_login_required, LENGTH_INDEFINITE) { - if (isLoggedIn()) return@showSnackbar - - // If a user is not logged in inside AnkiDroid, assume they have no AnkiWeb account - // and give them the option to sign up - setAction(CommonString.sign_up) { - binding.webView.loadUrl(getString(R.string.shared_decks_sign_up_url)) - } - } + showLoginRequiredSnackbar() // redirect user to /account/login // TODO: the result of login is typically redirecting the user to their decks // this should be improved if (redirectTimes++ < 3) { - val url = getString(R.string.shared_decks_login_url) - Timber.i("HTTP 429, redirecting to login: '$url'") - binding.webView.loadUrl(url) + redirectToLogin() } else { // Ensure that we do not have an infinite redirect Timber.w("HTTP 429 redirect limit exceeded, only displaying message") @@ -199,6 +167,29 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { } } + /** + * Tells the user they need to log in to download more decks, with a sign-up action for + * users without an AnkiDroid login. + */ + internal fun showLoginRequiredSnackbar() { + showSnackbar(CommonString.shared_decks_login_required, LENGTH_INDEFINITE) { + if (isLoggedIn()) return@showSnackbar + + // If a user is not logged in inside AnkiDroid, assume they have no AnkiWeb account + // and give them the option to sign up + setAction(CommonString.sign_up) { + binding.webView.loadUrl(getString(R.string.shared_decks_sign_up_url)) + } + } + } + + /** Loads the AnkiWeb login page in the WebView, where the user can log in. */ + internal fun redirectToLogin() { + val url = getString(R.string.shared_decks_login_url) + Timber.i("HTTP 429, redirecting to login: '$url'") + binding.webView.loadUrl(url) + } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val webViewClient = SharedDeckWebViewClient() @@ -206,7 +197,6 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { const val SHARED_DECKS_DOWNLOAD_FRAGMENT = "SharedDecksDownloadFragment" const val DOWNLOAD_FILE = "DownloadFile" - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) const val HTTP_STATUS_TOO_MANY_REQUESTS = 429 } @@ -344,3 +334,21 @@ data class DownloadFile( .toString() } } + +/** + * Whether the WebView's cookies indicate a logged-in AnkiWeb session. + * + * Used to tell a download failure caused by AnkiWeb's download limit for anonymous users + * apart from other failures: only a logged-out user can fix a 429 by logging in. + */ +internal fun isLoggedInToAnkiWeb(): Boolean = + try { + // ankiweb currently (2024-09-25) sets two cookies: + // * `ankiweb`, which is base64-encoded JSON + // * `has_auth`, which is 1 + // cookies are null after the user logs out, or if the site is first visited + CookieManager.getInstance().getCookie("https://ankiweb.net")?.contains("has_auth=1") == true + } catch (e: Exception) { + Timber.w(e, "Could not determine login status") + false + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt index 6989f7a1ffa7..085fbbb6d228 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt @@ -44,6 +44,7 @@ import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.compat.CompatHelper.Companion.registerReceiverCompat import com.ichi2.anki.shareddeck.SharedDecksActivity.Companion.DOWNLOAD_FILE import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope import com.ichi2.anki.utils.openUrl import com.ichi2.compose.theme.AnkiDroidTheme import com.ichi2.utils.ImportUtils @@ -175,9 +176,31 @@ class SharedDecksDownloadFragment : Fragment() { super.onViewCreated(view, savedInstanceState) downloadManager = (activity as SharedDecksActivity).downloadManager registerDownloadReceiver() + observeLoginRequired() downloadFile(fileToBeDownloaded) } + /** + * Handles the [DownloadPhase.LoginRequired] phase: shows the login prompt and returns to + * the shared decks WebView, opened on its login page, so the prompt is actionable even + * without a sign-up action. + * + * Handled in this collector rather than in the broadcast receiver which records the + * state: the receiver can fire while the activity is backgrounded, where + * FragmentManager work would throw, while this only runs while the screen is showing. + */ + private fun observeLoginRequired() { + viewModel.uiState.launchCollectionInLifecycleScope { state -> + if (state.phase == DownloadPhase.LoginRequired) { + Timber.i("Download requires login, returning to shared decks") + val sharedDecksActivity = activity as SharedDecksActivity + sharedDecksActivity.showLoginRequiredSnackbar() + sharedDecksActivity.redirectToLogin() + parentFragmentManager.popBackStack() + } + } + } + override fun onDestroyView() { stopDownloadProgressChecker() removeCancelConfirmationDialog() @@ -309,7 +332,7 @@ class SharedDecksDownloadFragment : Fragment() { // Halt execution if file doesn't have extension as 'apkg' or 'colpkg' if (!ImportUtils.isFileAValidDeck(fileName!!)) { Timber.i("File does not have 'apkg' or 'colpkg' extension, abort the deck opening task") - onDownloadFinished(isSuccessful = false, isInvalidDeckFile = true) + onDownloadFinished(DownloadOutcome.InvalidDeckFile) return null } @@ -321,7 +344,7 @@ class SharedDecksDownloadFragment : Fragment() { // Return if cursor is empty. if (!it.moveToFirst()) { Timber.i("Empty cursor, cannot continue further with success check and deck import") - onDownloadFinished(isSuccessful = false) + onDownloadFinished(DownloadOutcome.Failed) return null } @@ -331,8 +354,18 @@ class SharedDecksDownloadFragment : Fragment() { // Return if download was not successful. if (it.getInt(columnStatusIndex) != DownloadManager.STATUS_SUCCESSFUL) { Timber.i("Download could not be successful, update UI") - Timber.d("Status code -> ${it.getIntOrNull(columnStatusIndex)}, reason ${it.getIntOrNull(columnReasonIndex)}") - onDownloadFinished(isSuccessful = false) + val reason = it.getIntOrNull(columnReasonIndex) + Timber.d("Status code -> ${it.getIntOrNull(columnStatusIndex)}, reason $reason") + // DownloadManager reports the HTTP status code in COLUMN_REASON for 4xx/5xx + // failures: AnkiWeb replies 429 when an anonymous user has used up its + // download limit, asking them to log in (19876) + // a logged-in user is rate limited too ('Daily limit exceeded'), but cannot + // fix that by logging in, so only a logged-out user is sent the login prompt + val isLoginRequired = + reason == SharedDecksActivity.HTTP_STATUS_TOO_MANY_REQUESTS && !isLoggedInToAnkiWeb() + onDownloadFinished( + if (isLoginRequired) DownloadOutcome.LoginRequired else DownloadOutcome.Failed, + ) return null } @@ -340,7 +373,7 @@ class SharedDecksDownloadFragment : Fragment() { val localUri = it.getString(it.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)) if (localUri == null) { Timber.w("Completed download has no local URI") - onDownloadFinished(isSuccessful = false) + onDownloadFinished(DownloadOutcome.Failed) return null } return localUri.toUri().toFile() @@ -352,7 +385,7 @@ class SharedDecksDownloadFragment : Fragment() { getImportableDeck() } catch (exception: Exception) { Timber.w(exception) - onDownloadFinished(isSuccessful = false) + onDownloadFinished(DownloadOutcome.Failed) return } @@ -372,7 +405,7 @@ class SharedDecksDownloadFragment : Fragment() { openDownloadedDeck(context, downloadedFile) Timber.d("Download finished") - onDownloadFinished(isSuccessful = true) + onDownloadFinished(DownloadOutcome.Success) } } @@ -487,7 +520,7 @@ class SharedDecksDownloadFragment : Fragment() { // A successful DownloadManager entry can outlive its file, e.g. after another import. if (!downloadedFile.isFile) { Timber.w("Downloaded shared deck no longer exists") - onDownloadFinished(isSuccessful = false) + onDownloadFinished(DownloadOutcome.Failed) return } @@ -513,22 +546,46 @@ class SharedDecksDownloadFragment : Fragment() { } } + /** How a download ended, deciding what [onDownloadFinished] does next. */ + private enum class DownloadOutcome { + /** The download completed and the deck import was started. */ + Success, + + /** The completed file was not a deck. */ + InvalidDeckFile, + + /** AnkiWeb rate-limited a logged-out user, so a login is required. */ + LoginRequired, + + /** The download failed for any other reason; retry is offered. */ + Failed, + } + /** * Updates the UI after download completion or failure and marks the download as inactive. */ - private fun onDownloadFinished( - isSuccessful: Boolean, - isInvalidDeckFile: Boolean = false, - ) { - if (!isSuccessful) { - if (isInvalidDeckFile) { + private fun onDownloadFinished(outcome: DownloadOutcome) { + when (outcome) { + DownloadOutcome.Success -> {} + + DownloadOutcome.InvalidDeckFile -> { Timber.i("File is not a valid deck, hence return from the download screen") if (isVisible) { context?.let { showThemedToast(it, CommonString.import_log_no_apkg, false) } // Go back if file is not a deck and cannot be imported activity?.onBackPressedDispatcher?.onBackPressed() } - } else { + } + + DownloadOutcome.LoginRequired -> { + Timber.i("Download failed: AnkiWeb requires login to download more decks") + // discard the failed download: the login path offers no retry button, + // so nothing else would remove it (or its failed-download notification) + downloadManager.remove(downloadId) + viewModel.onLoginRequired() + } + + DownloadOutcome.Failed -> { Timber.i("Download failed, offer a retry") if (isVisible) { context?.let { showThemedToast(it, CommonString.something_wrong, false) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt index 17f170ec0531..e95bf48da145 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt @@ -373,7 +373,8 @@ private fun Actions( verticalArrangement = Arrangement.spacedBy(dimensions.space100), ) { when (phase) { - DownloadPhase.Downloading -> + // transient: the fragment shows the login prompt and leaves this screen + DownloadPhase.Downloading, DownloadPhase.LoginRequired -> FilledTonalButton( onClick = onCancelClick, colors = diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadViewModel.kt index b754e58d0b6f..603ca8a4d0b5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadViewModel.kt @@ -62,6 +62,12 @@ class SharedDecksDownloadViewModel : ViewModel() { Timber.i("download failed") uiState.update { it.copy(phase = DownloadPhase.Failed, percent = 0f, speedBytesPerSecond = 0, secondsRemaining = null) } } + + /** The download hit AnkiWeb's download limit for a logged-out user, so a login is needed. */ + fun onLoginRequired() { + Timber.i("login required to download more decks") + uiState.update { it.copy(phase = DownloadPhase.LoginRequired) } + } } /** Everything the download screen shows. */ @@ -89,6 +95,10 @@ enum class DownloadPhase { /** Failed. Try again and Open in browser are offered. */ Failed, + + /** The download hit AnkiWeb's limit for anonymous users. A login prompt is shown and the + * screen returns to the shared decks WebView, where the user can log in. */ + LoginRequired, } /** Whole numbers for 0 and 100, one decimal place in between, so the common cases read cleanly. */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SensibleSwipeDismissBehavior.kt b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SensibleSwipeDismissBehavior.kt index 6ebcf746b8a9..3ca47622237c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SensibleSwipeDismissBehavior.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SensibleSwipeDismissBehavior.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.anki.snackbar diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SnackbarPackageWorkaround.kt b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SnackbarPackageWorkaround.kt index 5d27484b92a9..691c914455b4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SnackbarPackageWorkaround.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SnackbarPackageWorkaround.kt @@ -1,16 +1,4 @@ -/* - * 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 @file:Suppress("PackageDirectoryMismatch") diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt index bb9e1f3f323c..949a7161ed77 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.anki.snackbar diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SwipeDismissBehaviorFix.kt b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SwipeDismissBehaviorFix.kt index 37358f542abc..9f8f3b34f363 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SwipeDismissBehaviorFix.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/SwipeDismissBehaviorFix.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.anki.snackbar diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/sync/MeteredSyncPermission.kt b/AnkiDroid/src/main/java/com/ichi2/anki/sync/MeteredSyncPermission.kt new file mode 100644 index 000000000000..4fd83ae8ed3a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/sync/MeteredSyncPermission.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +/** Permission to use a metered connection for one sync attempt. */ +enum class MeteredSyncPermission { + /** Apply the user's saved network preferences. */ + USE_PREFERENCES, + + /** Allow metered syncing for this attempt; the separate media restriction still applies. */ + ALLOW_METERED_SYNC_THIS_TIME, +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/sync/MeteredSyncPolicy.kt b/AnkiDroid/src/main/java/com/ichi2/anki/sync/MeteredSyncPolicy.kt index f30e05a1f156..5e27cc65eded 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/sync/MeteredSyncPolicy.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/sync/MeteredSyncPolicy.kt @@ -4,8 +4,10 @@ package com.ichi2.anki.sync import android.content.Context import androidx.appcompat.app.AlertDialog +import androidx.work.NetworkType import com.ichi2.anki.CommonString import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.settings.enums.ShouldFetchMedia import com.ichi2.utils.NetworkUtils.isActiveNetworkMetered import com.ichi2.utils.checkBoxPrompt import com.ichi2.utils.message @@ -20,7 +22,21 @@ import com.ichi2.utils.show */ object MeteredSyncPolicy { /** True when sync should be blocked/prompted because the network is metered. */ - fun shouldBlock(): Boolean = !Prefs.allowSyncOnMeteredConnections && isActiveNetworkMetered() + fun shouldBlock(): Boolean = getNetworkTypeRequiredForSync() == NetworkType.UNMETERED && isActiveNetworkMetered() + + /** + * Capture the network restriction in queued work so WorkManager can enforce it if the + * network changes before or during sync. Checking only the current connection is insufficient. + */ + fun getNetworkTypeRequiredForSync( + forMedia: Boolean = false, + permission: MeteredSyncPermission = MeteredSyncPermission.USE_PREFERENCES, + ): NetworkType = + when { + permission == MeteredSyncPermission.USE_PREFERENCES && !Prefs.allowSyncOnMeteredConnections -> NetworkType.UNMETERED + forMedia && Prefs.shouldFetchMedia == ShouldFetchMedia.ONLY_UNMETERED -> NetworkType.UNMETERED + else -> NetworkType.CONNECTED + } /** Persist the user's "don't ask again" choice from the warning dialog. */ fun setAlwaysAllow(allow: Boolean) { @@ -35,20 +51,25 @@ object MeteredSyncPolicy { * @param skipPrompt `true` if the user has already accepted a metered sync earlier in this * attempt (e.g. retry after conflict resolution); skips the warning. * @param onDialogShown invoked only if the warning dialog is displayed + * @param onConfirm receives the metered-network permission for this attempt */ context(context: Context) fun confirmThen( skipPrompt: Boolean = false, onDialogShown: () -> Unit, - onConfirm: () -> Unit, + onConfirm: (permission: MeteredSyncPermission) -> Unit, ) { - if (skipPrompt || !shouldBlock()) { - onConfirm() + if (skipPrompt) { + onConfirm(MeteredSyncPermission.ALLOW_METERED_SYNC_THIS_TIME) + return + } + if (!shouldBlock()) { + onConfirm(MeteredSyncPermission.USE_PREFERENCES) return } AlertDialog.Builder(context).show { message(CommonString.metered_sync_data_warning) - positiveButton(CommonString.dialog_continue) { onConfirm() } + positiveButton(CommonString.dialog_continue) { onConfirm(MeteredSyncPermission.ALLOW_METERED_SYNC_THIS_TIME) } negativeButton(CommonString.dialog_cancel) checkBoxPrompt(CommonString.button_do_not_show_again) { checked -> setAlwaysAllow(checked) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/OnHoldListener.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/OnHoldListener.kt index fd0263e32eba..2cc9b27fca21 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/OnHoldListener.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/OnHoldListener.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2024 Sumit Singh - * - * 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 Sumit Singh package com.ichi2.anki.ui diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt index af7cca5c0c4e..cabe100ae948 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2025 Hari Srinivasan - * - * 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) 2025 Hari Srinivasan package com.ichi2.anki.ui @@ -32,14 +19,15 @@ import timber.log.Timber class ResizablePaneManager( private val parentLayout: LinearLayout, private val divider: View, - private val leftPane: View, - private val rightPane: View, + private val startPane: View, + private val endPane: View, private val sharedPrefs: SharedPreferences, - private val leftPaneWeightKey: String, - private val rightPaneWeightKey: String, + private val startPaneWeightKey: String, + private val endPaneWeightKey: String, private val minWeight: Float = 0.5f, // Minimum weight for each pane private val dragColor: Int = divider.context.getColor(R.color.drag_divider_color), private val idleColor: Int = divider.context.getColor(R.color.idle_divider_color), + private val onResizingChanged: (Boolean) -> Unit = {}, ) { init { setupResizableDivider() @@ -50,8 +38,8 @@ class ResizablePaneManager( loadSavedWeights() var initialTouchX = 0f - var initialLeftWeight = 0f - var initialRightWeight = 0f + var initialStartWeight = 0f + var initialEndWeight = 0f divider.setOnHoverListener { _, event -> when (event.action) { @@ -74,8 +62,8 @@ class ResizablePaneManager( } divider.setOnTouchListener { v, event -> - val leftParams = leftPane.layoutParams as LinearLayout.LayoutParams - val rightParams = rightPane.layoutParams as LinearLayout.LayoutParams + val startParams = startPane.layoutParams as LinearLayout.LayoutParams + val endParams = endPane.layoutParams as LinearLayout.LayoutParams when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -87,36 +75,38 @@ class ResizablePaneManager( v.setBackgroundColor(dragColor) initialTouchX = event.rawX - initialLeftWeight = leftParams.weight - initialRightWeight = rightParams.weight + initialStartWeight = startParams.weight + initialEndWeight = endParams.weight + onResizingChanged(true) true } MotionEvent.ACTION_MOVE -> { v.parent.requestDisallowInterceptTouchEvent(true) - val deltaX = event.rawX - initialTouchX + // The start pane is on the right in RTL layouts, so it shrinks when dragging right. + val deltaX = (event.rawX - initialTouchX).invertIfRtl(parentLayout) val totalParentWidth = parentLayout.width.toFloat() if (totalParentWidth > 0) { // Avoid division by zero - val sumOfInitialWeights = initialLeftWeight + initialRightWeight + val sumOfInitialWeights = initialStartWeight + initialEndWeight // Calculate the change in weight based on the drag distance val weightDelta = (deltaX / totalParentWidth) * sumOfInitialWeights - var newLeftWeight = initialLeftWeight + weightDelta + var newStartWeight = initialStartWeight + weightDelta - // Clamp the new weight for the left pane + // Clamp the new weight for the start pane // Ensures it's not too small and not too large (leaving space for the other pane's minWeight) - newLeftWeight = newLeftWeight.coerceIn(minWeight, sumOfInitialWeights - minWeight) + newStartWeight = newStartWeight.coerceIn(minWeight, sumOfInitialWeights - minWeight) - val newRightWeight = sumOfInitialWeights - newLeftWeight + val newEndWeight = sumOfInitialWeights - newStartWeight // Apply the new weights - leftParams.weight = newLeftWeight - rightParams.weight = newRightWeight + startParams.weight = newStartWeight + endParams.weight = newEndWeight - leftPane.layoutParams = leftParams - rightPane.layoutParams = rightParams + startPane.layoutParams = startParams + endPane.layoutParams = endParams // Request layout update for the parent parentLayout.requestLayout() @@ -125,11 +115,12 @@ class ResizablePaneManager( } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { v.setBackgroundColor(idleColor) + onResizingChanged(false) // Save the new weights to SharedPreferences sharedPrefs.edit { - putFloat(leftPaneWeightKey, leftParams.weight) - putFloat(rightPaneWeightKey, rightParams.weight) + putFloat(startPaneWeightKey, startParams.weight) + putFloat(endPaneWeightKey, endParams.weight) } true } @@ -140,19 +131,19 @@ class ResizablePaneManager( private fun loadSavedWeights() { try { - val leftParams = leftPane.layoutParams as LinearLayout.LayoutParams - val rightParams = rightPane.layoutParams as LinearLayout.LayoutParams + val startParams = startPane.layoutParams as LinearLayout.LayoutParams + val endParams = endPane.layoutParams as LinearLayout.LayoutParams // Load saved weights from SharedPreferences - val savedLeftWeight = sharedPrefs.getFloat(leftPaneWeightKey, leftParams.weight) - val savedRightWeight = sharedPrefs.getFloat(rightPaneWeightKey, rightParams.weight) + val savedStartWeight = sharedPrefs.getFloat(startPaneWeightKey, startParams.weight) + val savedEndWeight = sharedPrefs.getFloat(endPaneWeightKey, endParams.weight) // Apply the saved weights - leftParams.weight = savedLeftWeight - rightParams.weight = savedRightWeight + startParams.weight = savedStartWeight + endParams.weight = savedEndWeight - leftPane.layoutParams = leftParams - rightPane.layoutParams = rightParams + startPane.layoutParams = startParams + endPane.layoutParams = endParams // Request layout update for the parent parentLayout.requestLayout() @@ -161,3 +152,5 @@ class ResizablePaneManager( } } } + +private fun Float.invertIfRtl(view: View): Float = if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) -this else this diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizingDivider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizingDivider.kt index 35fab7daef98..a891fdc2a17f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizingDivider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizingDivider.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2025 Hari Srinivasan - * - * 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) 2025 Hari Srinivasan package com.ichi2.anki.ui diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/ActivityAgnosticDialogs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/ActivityAgnosticDialogs.kt index 4c16d5cdd2cc..43b21ae9f4f1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/ActivityAgnosticDialogs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/ActivityAgnosticDialogs.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2023 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) 2023 Ashish Yadav package com.ichi2.anki.ui.dialogs diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/tools/AsyncDialogs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/tools/AsyncDialogs.kt index 404333b9d2c2..28a4f3d4aedf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/tools/AsyncDialogs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/tools/AsyncDialogs.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.anki.ui.dialogs.tools diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/preferences/screens/BackupLimitsPresenter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/preferences/screens/BackupLimitsPresenter.kt index 0640e7d62bd9..7652a598f374 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/preferences/screens/BackupLimitsPresenter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/preferences/screens/BackupLimitsPresenter.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.anki.ui.preferences.screens diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/managespace/ManageSpaceFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/managespace/ManageSpaceFragment.kt index bbe2ac778ef2..f9eeaebcb09c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/managespace/ManageSpaceFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/managespace/ManageSpaceFragment.kt @@ -25,12 +25,14 @@ import com.ichi2.anki.R import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.preferences.SettingsFragment import com.ichi2.anki.preferences.requirePreference +import com.ichi2.anki.progress.HasProgress +import com.ichi2.anki.progress.ProgressManager +import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.dialogs.tools.AsyncDialogBuilder.CheckedItems import com.ichi2.anki.ui.dialogs.tools.DialogResult import com.ichi2.anki.ui.dialogs.tools.awaitDialog import com.ichi2.anki.utils.getUserFriendlyErrorText -import com.ichi2.anki.withProgress import com.ichi2.preferences.TextWidgetPreference import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -66,8 +68,10 @@ sealed interface Size { class ManageSpaceViewModel( val app: Application, ) : AndroidViewModel(app), - CollectionDirectoryProvider { + CollectionDirectoryProvider, + HasProgress { override val collectionDirectory = CollectionManager.getCollectionDirectory() + override val progressManager = ProgressManager() val flowOfDeleteUnusedMediaSize = MutableStateFlow(Size.Calculating) val flowOfDeleteBackupsSize = MutableStateFlow(Size.Calculating) @@ -95,15 +99,17 @@ class ManageSpaceViewModel( } suspend fun deleteMediaFiles(filesNamesToDelete: List) { - try { - withCol { - media.trashFiles(filesNamesToDelete) - media.emptyTrash() + progressManager.withProgress(message = ManageSpaceProgress.DELETING_MEDIA) { + try { + withCol { + media.trashFiles(filesNamesToDelete) + media.emptyTrash() + } + } finally { + launchCalculationOfSizeOfEverything() + launchCalculationOfCollectionSize() + launchSearchForUnusedMedia() } - } finally { - launchCalculationOfSizeOfEverything() - launchCalculationOfCollectionSize() - launchSearchForUnusedMedia() } } @@ -121,12 +127,14 @@ class ManageSpaceViewModel( } suspend fun deleteBackups(backupsToDelete: List) { - try { - withCol { BackupManager.deleteBackups(this@withCol, backupsToDelete) } - } finally { - launchCalculationOfBackupsSize() - launchCalculationOfCollectionSize() - launchCalculationOfSizeOfEverything() + progressManager.withProgress(message = ManageSpaceProgress.DELETING_BACKUPS) { + try { + withCol { BackupManager.deleteBackups(this@withCol, backupsToDelete) } + } finally { + launchCalculationOfBackupsSize() + launchCalculationOfCollectionSize() + launchCalculationOfSizeOfEverything() + } } } @@ -142,13 +150,15 @@ class ManageSpaceViewModel( } suspend fun deleteCollection() { - try { - CollectionManager.deleteCollectionDirectory() // Executed in withQueue - } finally { - launchCalculationOfBackupsSize() - launchCalculationOfSizeOfEverything() - launchCalculationOfCollectionSize() - launchSearchForUnusedMedia() + progressManager.withProgress(message = ManageSpaceProgress.DELETING_COLLECTION) { + try { + CollectionManager.deleteCollectionDirectory() // Executed in withQueue + } finally { + launchCalculationOfBackupsSize() + launchCalculationOfSizeOfEverything() + launchCalculationOfCollectionSize() + launchSearchForUnusedMedia() + } } } @@ -194,6 +204,12 @@ class ManageSpaceViewModel( } } +enum class ManageSpaceProgress { + DELETING_MEDIA, + DELETING_BACKUPS, + DELETING_COLLECTION, +} + /************************************************************************************************** ******************************************** Fragment ******************************************** **************************************************************************************************/ @@ -225,6 +241,8 @@ class ManageSpaceFragment : SettingsFragment() { ).forEach { (flowOfSize, preference) -> lifecycleScope.launch { flowOfSize.collect { size -> preference.setWidgetTextBy(size) } } } + + observeProgress(viewModel) { progress -> getString(progress.messageRes) } } override fun onViewCreated( @@ -257,9 +275,7 @@ class ManageSpaceFragment : SettingsFragment() { val checkedItems = deleteFilesPromptResult.checkedItems val filesNamesToDelete = unusedFileNames.filterIndexed { index, _ -> checkedItems[index] } - withProgress(CommonString.delete_media_message) { - viewModel.deleteMediaFiles(filesNamesToDelete) - } + viewModel.deleteMediaFiles(filesNamesToDelete) } } else { showSnackbarIfCalculatingOrError(size) @@ -287,9 +303,7 @@ class ManageSpaceFragment : SettingsFragment() { val checkedItems = chooseBackupsPromptResult.checkedItems val backupsToDelete = backupFiles.filterIndexed { index, _ -> checkedItems[index] } - withProgress(CommonString.progress__deleting_backups) { - viewModel.deleteBackups(backupsToDelete) - } + viewModel.deleteBackups(backupsToDelete) } } else { showSnackbarIfCalculatingOrError(size) @@ -318,9 +332,7 @@ class ManageSpaceFragment : SettingsFragment() { if (deleteCollectionPromptResult is DialogResult.Ok) { try { - withProgress(CommonString.progress__deleting_collection) { - viewModel.deleteCollection() - } + viewModel.deleteCollection() } finally { exitProcess(0) } @@ -409,3 +421,12 @@ class ManageSpaceFragment : SettingsFragment() { } } } + +@get:StringRes +private val ManageSpaceProgress.messageRes: Int + get() = + when (this) { + ManageSpaceProgress.DELETING_MEDIA -> CommonString.delete_media_message + ManageSpaceProgress.DELETING_BACKUPS -> CommonString.progress__deleting_backups + ManageSpaceProgress.DELETING_COLLECTION -> CommonString.progress__deleting_collection + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/InternetPermissionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/InternetPermissionFragment.kt index 79da6abfd0c5..93fa1bf1784a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/InternetPermissionFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/InternetPermissionFragment.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2025 Akshita Tiwary - * - * 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) 2025 Akshita Tiwary + package com.ichi2.anki.ui.windows.permissions import android.os.Bundle diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt index 0ff66eb17d8e..af0be0f60549 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt @@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge import androidx.fragment.app.commit import com.ichi2.anki.AnkiActivity import com.ichi2.anki.CommonString +import com.ichi2.anki.InitialActivity import com.ichi2.anki.R import com.ichi2.anki.StoragePermissionSet import com.ichi2.anki.common.utils.android.showThemedToast @@ -46,7 +47,7 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { enableEdgeToEdge() setViewBinding(binding) - binding.continueButton.setOnClickListener { finish() } + binding.continueButton.setOnClickListener { decideStorageAndFinish() } // #20881: Activity should not be launchd without extras val permissionSet = intent.getParcelableExtraCompat(EXTRA_PERMISSIONS_SET) @@ -62,6 +63,7 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { val hasAllPermissions = bundle.getBoolean(HAS_ALL_PERMISSIONS_KEY) setContinueButtonEnabled(hasAllPermissions) } + setFragmentResultListener(RESULT_COMPLETE) { _, _ -> decideStorageAndFinish() } supportFragmentManager.commit { replace(R.id.fragment_container, permissionsFragment) @@ -74,9 +76,31 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { binding.continueButton.isEnabled = isEnabled } + /** + * Records the storage decision and closes the screen: the granted permissions + * determine the default collection path. + * + * Called on 'Continue', or when the fragment reports [RESULT_COMPLETE]. + * Preserves an existing collection path. + * + * @see InitialActivity.decideStorageIfUndecided + */ + private fun decideStorageAndFinish() { + InitialActivity.decideStorageIfUndecided(this) + finish() + } + companion object { const val EXTRA_PERMISSIONS_SET = "permissionsSet" + /** + * Fragment result request key: all the fragment's permissions are granted. + * + * Signals the activity to persist the + * [storage decision][com.ichi2.anki.common.storage.StorageDecision], and then close. + */ + const val RESULT_COMPLETE = "result_complete" + fun getIntent( context: Context, permissionsSet: StoragePermissionSet, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt index b97363f6f976..53b1c927c064 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt @@ -8,6 +8,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi +import androidx.fragment.app.setFragmentResult import com.ichi2.anki.R import com.ichi2.anki.common.permissions.MANAGE_EXTERNAL_STORAGE import com.ichi2.anki.databinding.FragmentPermissionsStartingAt30Binding @@ -29,7 +30,8 @@ class PermissionsStartingAt30Fragment : PermissionsFragment(R.layout.fragment_pe ActivityResultContracts.StartActivityForResult(), ) { if (hasAllPermissions()) { - requireActivity().finish() + // the activity records the storage decision and finishes + setFragmentResult(PermissionsActivity.RESULT_COMPLETE, Bundle()) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt index 7f5f82d09f61..690fd7f110a5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt @@ -53,6 +53,7 @@ import com.ichi2.anki.dialogs.tags.TagsDialogFactory import com.ichi2.anki.dialogs.tags.TagsDialogListener import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.preferences.reviewer.ViewerAction +import com.ichi2.anki.preferences.reviewer.WhiteboardAction import com.ichi2.anki.previewer.CardViewerActivity import com.ichi2.anki.previewer.CardViewerFragment import com.ichi2.anki.previewer.TypeAnswer @@ -96,6 +97,7 @@ import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt import kotlin.reflect.jvm.jvmName +import kotlin.time.Duration.Companion.milliseconds class ReviewerFragment : CardViewerFragment(R.layout.fragment_reviewer), @@ -384,11 +386,14 @@ class ReviewerFragment : binding.root.setOnGenericMotionListener { _, event -> bindingMap.onGenericMotionEvent(event) } - if (bindingMap.isBound(Gesture.SHAKE)) { + if (isBound(Gesture.SHAKE)) { shakeDetector = AnkiShakeDetector.createInstance(requireContext(), this) } } + private fun isBound(gesture: Gesture): Boolean = + bindingMap.isBound(gesture) || BindingMap(sharedPrefs(), WhiteboardAction.entries).isBound(gesture) + private fun setupAnswerButtons() { if (!Prefs.showAnswerButtons) { binding.answerArea.isVisible = false @@ -710,7 +715,7 @@ class ReviewerFragment : isScrollingJob?.cancel() isScrollingJob = lifecycleScope.launch { - delay(300) + delay(300.milliseconds) isScrolling = false } } @@ -726,7 +731,11 @@ class ReviewerFragment : gestureParser.parse(url, scale, webView) { gesture -> if (gesture == null) return@parse Timber.v("ReviewerFragment::onGesture %s", gesture) - bindingMap.onGesture(gesture) + // Stylus mode passes finger input through to the WebView. Whiteboard + // bindings still take priority while the whiteboard is visible. + if (whiteboardFragment?.takeIf { it.isVisible }?.onGesture(gesture) != true) { + bindingMap.onGesture(gesture) + } } true } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt index 3e7aa4a09957..3329fd0eda9f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt @@ -67,6 +67,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import org.intellij.lang.annotations.Language import timber.log.Timber +import kotlin.time.Duration.Companion.seconds import com.ichi2.anki.common.destinations.Destination as NavigateDestination class ReviewerViewModel( @@ -217,7 +218,7 @@ class ReviewerViewModel( } else { typedAnswerResult.complete("") } - typedAnswer = withTimeoutOrNull(1000L) { + typedAnswer = withTimeoutOrNull(1.seconds) { typedAnswerResult.await() } ?: "" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/StudyScreenRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/StudyScreenRepository.kt index d37fda429804..89bfe645627b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/StudyScreenRepository.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/StudyScreenRepository.kt @@ -4,6 +4,7 @@ package com.ichi2.anki.ui.windows.reviewer import com.ichi2.anki.CollectionManager import com.ichi2.anki.common.time.TimeManager +import com.ichi2.anki.common.utils.ext.cardStateCustomizer import com.ichi2.anki.preferences.reviewer.MenuDisplayType import com.ichi2.anki.preferences.reviewer.ReviewerMenuRepository import com.ichi2.anki.preferences.reviewer.ViewerAction @@ -11,7 +12,6 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.PrefsRepository import com.ichi2.anki.settings.enums.ToolbarPosition import com.ichi2.anki.utils.CollectionPreferences -import com.ichi2.anki.utils.ext.cardStateCustomizer import timber.log.Timber import java.net.BindException import java.net.ServerSocket @@ -56,7 +56,7 @@ class StudyScreenRepository( fun generateStateMutationKey(): String = TimeManager.time.intTimeMS().toString() - suspend fun getCustomSchedulingJs(): String = CollectionManager.withCol { cardStateCustomizer } + suspend fun getCustomSchedulingJs(): String = CollectionManager.withCol { config.cardStateCustomizer } suspend fun getShouldShowNextTimes(): Boolean = CollectionPreferences.getShowIntervalOnButtons() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/AudioPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/AudioPlayer.kt deleted file mode 100644 index f4e4ace3d59f..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/AudioPlayer.kt +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package com.ichi2.anki.ui.windows.reviewer.audiorecord - -import android.media.MediaPlayer -import timber.log.Timber -import java.io.Closeable -import java.io.IOException - -class AudioPlayer : Closeable { - private val mediaPlayer = MediaPlayer() - - var isPlaying = false - private set - var isPaused = false - private set - var isPrepared = false - - var onCompletion: (() -> Unit)? = null - - val duration: Int - get() = if (isPrepared) mediaPlayer.duration else 0 - val currentPosition: Int - get() = if (isPrepared) mediaPlayer.currentPosition else 0 - - init { - mediaPlayer.setOnCompletionListener { - isPlaying = false - isPaused = false - onCompletion?.invoke() - } - } - - fun play( - filePath: String, - onPrepared: () -> Unit, - ) { - Timber.i("AudioPlayer::play (isPlaying %b)", isPlaying) - try { - mediaPlayer.reset() - isPrepared = false - isPlaying = false - isPaused = false - - mediaPlayer.setDataSource(filePath) - mediaPlayer.setOnPreparedListener { mp -> - isPrepared = true - mp.start() - isPlaying = true - onPrepared() - } - mediaPlayer.prepareAsync() - } catch (exception: IOException) { - Timber.w(exception, "Could not play file %s", filePath) - close() - } - } - - fun pause() { - Timber.i("AudioPlayer::pause (isPlaying %b)", isPlaying) - if (isPlaying) { - mediaPlayer.pause() - isPlaying = false - isPaused = true - } - } - - fun resume() { - Timber.i("AudioPlayer::resume (isPaused %b)", isPaused) - if (isPaused) { - mediaPlayer.start() - isPlaying = true - isPaused = false - } - } - - fun replay() { - Timber.i("AudioPlayer::replay (isPlaying %b) (isPrepared %b)", isPlaying, isPrepared) - if (isPrepared) { - mediaPlayer.seekTo(0) - mediaPlayer.start() - isPlaying = true - isPaused = false - } - } - - override fun close() { - Timber.i("AudioPlayer::close (isPlaying %b) (isPrepared %b)", isPlaying, isPrepared) - mediaPlayer.reset() - isPrepared = false - isPlaying = false - isPaused = false - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt index 89aa041d572e..2c3c22724bf3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt @@ -4,124 +4,96 @@ package com.ichi2.anki.ui.windows.reviewer.audiorecord import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.media3.common.Player import com.ichi2.anki.common.android.appContext import com.ichi2.anki.recorder.AudioRecorder -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import com.ichi2.anki.recorder.PlayerState +import com.ichi2.anki.recorder.RecorderState +import com.ichi2.anki.recorder.VoicePlayer +import com.ichi2.anki.recorder.VoiceRecorder +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.io.File +import kotlin.time.Duration class CheckPronunciationViewModel( - private val audioRecorder: AudioRecorder = AudioRecorder(appContext), - private val audioPlayer: AudioPlayer = AudioPlayer(), + audioRecorder: AudioRecorder = AudioRecorder(appContext), + createPlayer: () -> Player = { VoicePlayer.createExoPlayer(appContext) }, + recordingsDirectory: File = VoiceRecorder.recordingsDirectory(appContext), ) : ViewModel() { - init { - addCloseable(audioPlayer) - addCloseable(audioRecorder) + private val recorder = VoiceRecorder(audioRecorder, recordingsDirectory, viewModelScope) + private val player = VoicePlayer(viewModelScope, createPlayer) - audioPlayer.onCompletion = { - viewModelScope.launch { - playbackProgressFlow.emit(playbackProgressBarMaxFlow.value) - isPlayingFlow.emit(false) - } - } + init { + addCloseable(player) + addCloseable(recorder) } - val playbackProgressFlow = MutableStateFlow(0) - val playbackProgressBarMaxFlow = MutableStateFlow(1) - val isPlayingFlow = MutableStateFlow(false) + val playbackProgressFlow: Flow = player.position.map { it.inWholeMilliseconds.toInt() } + val playbackProgressBarMaxFlow: Flow = + player.state + .map { state -> + val duration = (state as? PlayerState.Ready)?.duration ?: Duration.ZERO + duration.inWholeMilliseconds.toInt().coerceAtLeast(1) + }.distinctUntilChanged() + val isPlayingFlow: Flow = + player.state + .map { state -> state is PlayerState.Ready && state.isPlaying } + .distinctUntilChanged() val replayFlow = MutableSharedFlow() val isPlaybackVisibleFlow = MutableStateFlow(false) - private var progressBarUpdateJob: Job? = null - private val currentFile get() = audioRecorder.currentFile - private val isPlaying get() = audioPlayer.isPlaying + private val currentFile get() = (recorder.state.value as? RecorderState.Recorded)?.file fun onRecordingStarted() { - audioRecorder.start() onCancelPlayback() + recorder.start() } fun onRecordingCancelled() { - audioRecorder.stop() + recorder.discard() } fun onRecordingCompleted() { - audioRecorder.stop() - viewModelScope.launch { - isPlaybackVisibleFlow.emit(true) - isPlayingFlow.emit(false) - playbackProgressFlow.emit(0) - } + if (!recorder.stop()) return + isPlaybackVisibleFlow.value = true } fun pausePlayback() { - if (isPlaying) { - progressBarUpdateJob?.cancel() - audioPlayer.pause() - viewModelScope.launch { - isPlayingFlow.emit(false) - } - } + player.pause() } fun onPlayOrReplay() { if (!isPlaybackVisibleFlow.value) return - - if (isPlaying) { - replayCurrentFile() - viewModelScope.launch { - replayFlow.emit(Unit) + val file = currentFile ?: return + val state = player.state.value + when { + state == PlayerState.Empty || state == PlayerState.Failed -> { + player.load(file) + player.play() + } + state is PlayerState.Ready && state.isPlaying -> { + player.seekTo(Duration.ZERO) + viewModelScope.launch { replayFlow.emit(Unit) } } - } else if (audioPlayer.isPaused) { - viewModelScope.launch { isPlayingFlow.emit(true) } - audioPlayer.resume() - launchProgressBarUpdateJob() - } else { - viewModelScope.launch { isPlayingFlow.emit(true) } - playCurrentFile() + else -> player.play() } } fun onCancelPlayback() { - progressBarUpdateJob?.cancel() - audioPlayer.close() - viewModelScope.launch { - isPlaybackVisibleFlow.emit(false) - playbackProgressFlow.emit(0) - isPlayingFlow.emit(false) + player.unload() + if (recorder.state.value is RecorderState.Recorded) { + recorder.discard() } + isPlaybackVisibleFlow.value = false } fun resetAll() { onRecordingCancelled() onCancelPlayback() } - - private fun playCurrentFile() { - val filePath = currentFile?.absolutePath ?: return - audioPlayer.play(filePath) { - viewModelScope.launch { - playbackProgressBarMaxFlow.emit(audioPlayer.duration) - launchProgressBarUpdateJob() - } - } - } - - private fun replayCurrentFile() { - audioPlayer.replay() - launchProgressBarUpdateJob() - } - - private fun launchProgressBarUpdateJob() { - progressBarUpdateJob?.cancel() - progressBarUpdateJob = - viewModelScope.launch { - while (isPlaying) { - playbackProgressFlow.emit(audioPlayer.currentPosition) - delay(50L) - } - } - } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt index 51913866d04c..7cfd081c45b8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt @@ -17,6 +17,7 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.PopupWindow import androidx.activity.OnBackPressedCallback +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu @@ -59,7 +60,8 @@ class WhiteboardFragment : Fragment(R.layout.fragment_whiteboard), PopupMenu.OnMenuItemClickListener, DispatchKeyEventListener { - private val viewModel: WhiteboardViewModel by viewModels { + @VisibleForTesting + internal val viewModel: WhiteboardViewModel by viewModels { WhiteboardViewModel.factory(AnkiDroidApp.sharedPrefs()) } @@ -210,7 +212,7 @@ class WhiteboardFragment : 4 -> Gesture.FOUR_FINGER_TAP else -> return@setOnMultiTouchListener } - val result = bindingMap.onGesture(gesture) + val result = onGesture(gesture) if (!result) { gestureFallbackListener?.invoke(gesture) } @@ -218,11 +220,14 @@ class WhiteboardFragment : } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (event.action != KeyEvent.ACTION_DOWN) return false + if (isHidden || event.action != KeyEvent.ACTION_DOWN) return false return bindingMap.onKeyDown(event) } - fun onScreenShake(): Boolean = bindingMap.onGesture(Gesture.SHAKE) + fun onScreenShake(): Boolean = !isHidden && onGesture(Gesture.SHAKE) + + /** Handles gestures detected on the whiteboard or on the card beneath it in stylus mode. */ + fun onGesture(gesture: Gesture): Boolean = bindingMap.onGesture(gesture) /** * Sets up observers for the ViewModel's flows. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt index 9bdd06431f74..4cab03fee655 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt @@ -131,7 +131,7 @@ class WhiteboardView : View { /** * Handles user touch input for drawing and erasing. - * Ignores finger input if stylus-only mode is enabled. + * In stylus-only mode, finger input passes through to the card beneath the whiteboard. */ override fun onTouchEvent(event: MotionEvent): Boolean { if (event.pointerCount >= 2) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/Exceptions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/Exceptions.kt index 9bab44cb242a..066a0ea0568f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/Exceptions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/Exceptions.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.anki.utils diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/HanUnificationDetector.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/HanUnificationDetector.kt new file mode 100644 index 000000000000..68c3633af74f --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/HanUnificationDetector.kt @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2025 Snowiee + +package com.ichi2.anki.utils + +/** + * Detects Han Unification issues in note field content. + * + * Han Unification is a Unicode feature where visually distinct Chinese, Japanese, and Korean + * characters are assigned the same code point. This can cause Japanese kanji to render as + * Chinese hanzi (or vice versa) depending on the system font, unless the HTML explicitly + * specifies the language via the `lang` attribute. + * + * Example: U+9AA8 (骨) renders differently in Japanese vs Chinese without lang="ja" or lang="zh" + * + * This detector is used during note creation/editing to warn users about potential rendering + * issues before they encounter them during review. + * + * @see Han Unification on Wikipedia + */ + +object HanUnificationDetector { + /** + * Common CJK characters known to have significant visual differences between + * Japanese and Chinese rendering. This is a curated list for detection for now. + * + * Source: https://github.com/ankidroid/Anki-Android/issues/19431 + */ + private val KNOWN_PROBLEMATIC_CHARS = + setOf( + '\u9AA8', // 骨 + '\u76F4', // 直 + '\u76F8', // 相 + '\u79C1', // 私 + '\u8FBC', // 込 + '\u904E', // 過 + '\u98DF', // 食 + '\u6700', // 最 + '\u5408', // 合 + '\u5009', // 倉 + '\u5C64', // 層 + '\u66FE', // 曾 + '\u671D', // 朝 + '\u6CBB', // 治 + '\u7384', // 玄 + '\u7DF4', // 練 + '\u7F72', // 署 + '\u89D2', // 角 + '\u8AAA', // 說 + '\u8D08', // 贈 + '\u9038', // 逸 + '\u9244', // 鉄 + '\u96C6', // 集 + ) + + /** + * Detects potential Han Unification issues in text content. + * + * Checks if the text contains known CJK characters that render differently + * in Japanese vs Chinese contexts without proper language specification. + * + * @param text The text content to analyze (plain text, not HTML) + * @return true if any problematic characters are found, false otherwise + */ + fun detectIssue(text: String): Boolean { + if (text.isEmpty()) { + return false + } + + // Early exit on first problematic character found + return text.any { char -> KNOWN_PROBLEMATIC_CHARS.contains(char) } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueueObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueueObserver.kt new file mode 100644 index 000000000000..6e1842c1975f --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueueObserver.kt @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2026 Ashish Yadav + +package com.ichi2.anki.utils + +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.withStarted +import com.ichi2.anki.utils.MessageQueue.Message +import com.ichi2.anki.utils.MessageQueue.MessageId +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** Shows the oldest of [messages] once the view is started, and acknowledges it when [show] returns. */ +fun Fragment.observeMessages( + messages: StateFlow>>, + acknowledge: (MessageId) -> Unit, + show: suspend (T) -> Unit, +) { + val owner = viewLifecycleOwner + owner.lifecycleScope.launch { + owner.repeatOnLifecycle(Lifecycle.State.CREATED) { + messages.mapNotNull { it.firstOrNull() }.collect { pending -> + owner.withStarted {} + show(pending.message) + acknowledge(pending.id) + } + } + } +} + +/** Shows the dialog and returns once it is dismissed. Cancelling the caller dismisses it. */ +suspend fun AlertDialog.Builder.showAndAwaitDismissal(block: AlertDialog.Builder.() -> Unit) { + block() + suspendCancellableCoroutine { continuation -> + val dialog = setOnDismissListener { continuation.resume(Unit) }.show() + continuation.invokeOnCancellation { dialog.dismiss() } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/MimeTypeUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MimeTypeUtils.kt index aec5014b1e35..99fd52b0690e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/MimeTypeUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MimeTypeUtils.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2025 argon2r - * - * 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) 2025 argon2r package com.ichi2.anki.utils diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ShortcutUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ShortcutUtils.kt index 370fc04a765f..204aefb9c968 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ShortcutUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ShortcutUtils.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2025 Snowiee - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A - * PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2025 Snowiee package com.ichi2.anki.utils diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/BitmapDrawable.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/BitmapDrawable.kt index 2d11cb592a7f..859443b0ca89 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/BitmapDrawable.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/BitmapDrawable.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.utils.ext diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Collection.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Collection.kt index 75bb84a3ad98..8bdb65954be1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Collection.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Collection.kt @@ -4,7 +4,6 @@ package com.ichi2.anki.utils.ext import androidx.annotation.CheckResult import anki.collection.OpChangesWithCount -import anki.config.ConfigKey import anki.search.SearchNode import com.ichi2.anki.Flag import com.ichi2.anki.libanki.Card @@ -18,15 +17,6 @@ fun Collection.setUserFlagForCards( flag: Flag, ): OpChangesWithCount = setUserFlagForCards(cids, flag.code) -/** - * The `Custom scheduling` global setting in deck options. - */ -var Collection.cardStateCustomizer: String - get() = config.getString(ConfigKey.String.CARD_STATE_CUSTOMIZER) - set(value) { - config.setString(ConfigKey.String.CARD_STATE_CUSTOMIZER, value) - } - /** @see Collection.getCard */ fun Collection.getCardOrNull(id: CardId): Card? = runCatching { getCard(id) }.getOrNull() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt index 2927134fb198..58b83b282971 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt @@ -7,19 +7,6 @@ import com.ichi2.anki.libanki.Config // Extensions for com.ichi2.anki.libanki.Config -/** - * When enabled, simple text searches automatically ignore accents. - * - * When enabled, both 'uber' and 'über' match `["uber", "über", "Über"]`. - * - * [Manual: Searching - Ignoring accents/combining characters](https://docs.ankiweb.net/searching.html#ignoring-accentscombining-characters) - * - * [Added in Anki#1667](https://github.com/ankitects/anki/pull/1667) - */ -var Config.ignoreAccentsInSearch - get() = getBool(ConfigKey.Bool.IGNORE_ACCENTS_IN_SEARCH) - set(value) = setBool(ConfigKey.Bool.IGNORE_ACCENTS_IN_SEARCH, value) - /** * Default search text for the card browser * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt index 5eb88dd786b3..6e71c185ae74 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt @@ -14,3 +14,6 @@ fun Decks.update( block(deck) this.save(deck) } + +/** @see Decks.remove */ +fun Decks.remove(deckId: DeckId) = remove(listOf(deckId)) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Double.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Double.kt index e7b149e48116..1d11955b09fb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Double.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Double.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2026 Shaan Narendran - * - * 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) 2026 Shaan Narendran package com.ichi2.anki.utils.ext diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/String.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/String.kt index 2791c82e082a..a5f3ee043e21 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/String.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/String.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.utils.ext diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/TabLayout.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/TabLayout.kt index 255e0f49515e..04d4182d343a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/TabLayout.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/TabLayout.kt @@ -1,18 +1,6 @@ -/* - * Copyright (c) 2025 Rakshita Chauhan - * - * 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) 2025 Rakshita Chauhan + package com.ichi2.anki.utils.ext import com.google.android.material.tabs.TabLayout diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt index fc90dfa507fc..060eff91f433 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2025 Danilo Mendes - * - * 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) 2025 Danilo Mendes package com.ichi2.anki.utils.ext @@ -20,8 +7,33 @@ import android.content.res.ColorStateList import android.graphics.Rect import android.view.MotionEvent import android.view.View +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import com.google.android.material.color.MaterialColors +/** + * Runs [action] on each global layout until [lifecycleOwner] is destroyed. + * Use a fragment's `viewLifecycleOwner` when observing its views. + */ +fun View.onGlobalLayout( + lifecycleOwner: LifecycleOwner, + action: () -> Unit, +) { + if (lifecycleOwner.lifecycle.currentState == Lifecycle.State.DESTROYED) return + val listener = OnGlobalLayoutListener(action) + viewTreeObserver.addOnGlobalLayoutListener(listener) + lifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + // The observer is shared with the window and can outlive the lifecycle owner. + viewTreeObserver.takeIf { it.isAlive }?.removeOnGlobalLayoutListener(listener) + } + }, + ) +} + fun View.isTouchWithinBounds(event: MotionEvent): Boolean { val rect = Rect().apply { getDrawingRect(this) } return rect.contains((left + event.x).toInt(), (top + event.y).toInt()) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/web/HttpFetcher.kt b/AnkiDroid/src/main/java/com/ichi2/anki/web/HttpFetcher.kt index ae8ad23d4762..8db5d63e3252 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/web/HttpFetcher.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/web/HttpFetcher.kt @@ -1,22 +1,9 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * Copyright (c) 2020 Mike Hardy - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda +// SPDX-FileCopyrightText: Copyright (c) 2020 Mike Hardy package com.ichi2.anki.web import com.ichi2.anki.common.utils.annotation.KotlinCleanup diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/AppLoadedFromBackupWorkaround.kt b/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/AppLoadedFromBackupWorkaround.kt index 262f84dac262..747383b2fc40 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/AppLoadedFromBackupWorkaround.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/AppLoadedFromBackupWorkaround.kt @@ -34,8 +34,6 @@ object AppLoadedFromBackupWorkaround { if (AnkiDroidApp.isInitialized) { return false } - // TODO: Timber likely does not work on this path - maybe add a check in IntentHandler - // #7630: Can be triggered with `adb shell bmgr restore com.ichi2.anki` after AnkiDroid settings are changed. // Application.onCreate() is not called if: // * The App was open diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/FullDraggableContainerFix.kt b/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/FullDraggableContainerFix.kt index 13b6a65e517f..4b140ca9f786 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/FullDraggableContainerFix.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/FullDraggableContainerFix.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.anki.workarounds diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt new file mode 100644 index 000000000000..65ad454bd628 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.worker + +import androidx.work.Data +import androidx.work.workDataOf +import anki.sync.syncAuth +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.sync.SyncAuth + +private const val SYNC_AUTH_KEY = "syncAuth" + +/** Preserve all sync settings, including the network timeout, when passing auth to either worker. */ +internal fun SyncAuth.toWorkData(): Data = workDataOf(SYNC_AUTH_KEY to toProto().toByteArray()) + +internal fun Data.toSyncAuth(): SyncAuth? { + getByteArray(SYNC_AUTH_KEY)?.let { return SyncAuth(anki.sync.SyncAuth.parseFrom(it)) } + + // Temporary workaround for an app update from <2.26.0 + val hkey = getString("hkey") ?: return null + return SyncAuth( + syncAuth { + this.hkey = hkey + getString("endpoint")?.let { endpoint = it } + ioTimeoutSecs = Prefs.networkTimeoutSecs + }, + ) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt index 54c2e7d95df7..6c0034dc4f5f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt @@ -8,11 +8,11 @@ import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import androidx.annotation.VisibleForTesting +import androidx.concurrent.futures.await import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.Constraints import androidx.work.CoroutineWorker -import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo import androidx.work.NetworkType @@ -22,7 +22,6 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters import anki.sync.MediaSyncProgress -import anki.sync.syncAuth import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString @@ -32,6 +31,7 @@ import com.ichi2.anki.cancelMediaSync import com.ichi2.anki.common.permissions.canPostNotifications import com.ichi2.anki.notifications.NotificationId import com.ichi2.anki.receiver.CopyToClipboardReceiver +import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.SyncAuth import com.ichi2.anki.sync.syncMedia import com.ichi2.anki.ui.internationalization.sentenceCase @@ -39,8 +39,19 @@ import com.ichi2.anki.utils.ext.trySetForeground import com.ichi2.utils.TruncatedString import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import net.ankiweb.rsdroid.Backend import timber.log.Timber +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Held while a media sync runs. A profile switch holds it until the process dies, + * so a media sync cannot start part way through a restart. + */ +val mediaSyncLock = Mutex() class SyncMediaWorker( context: Context, @@ -56,22 +67,19 @@ class SyncMediaWorker( override suspend fun doWork(): Result { Timber.v("SyncMediaWorker::doWork") + return mediaSyncLock.withLockUnlessSwitching { doWorkHoldingLock() } + } + private suspend fun doWorkHoldingLock(): Result { try { - val auth = - syncAuth { - hkey = inputData.getString(HKEY_KEY)!! - inputData.getString(ENDPOINT_KEY)?.let { - endpoint = it - } - }.let(::SyncAuth) + val auth = requireNotNull(inputData.toSyncAuth()) // The collection must be open, but we should not block collection operations while // `syncMedia` is executing, the app should be usable during a background media sync val backend = CollectionManager.getColUnsafe().backend backend.syncMedia(auth) - delay(1000) // avoid notifications if sync occurs too quickly + delay(1.seconds) // avoid notifications if sync occurs too quickly if (backend.mediaSyncStatus().active) { Timber.i("Showing SyncMediaWorker's notification") trySetForeground(getForegroundInfo()) @@ -130,7 +138,7 @@ class SyncMediaWorker( val notificationText = syncProgress.run { "$added $removed $checked" } notify(getProgressNotification(notificationText)) } - delay(NOTIFICATION_UPDATE_RATE_MS) + delay(NOTIFICATION_UPDATE_RATE) } } @@ -193,41 +201,66 @@ class SyncMediaWorker( } companion object { - private const val HKEY_KEY = "hkey" - private const val ENDPOINT_KEY = "endpoint" - const val NOTIFICATION_UPDATE_RATE_MS = 500L + val NOTIFICATION_UPDATE_RATE = 500.milliseconds + + /** Ensure a metered network request approval only affects the intended request. */ + private val schedulingLock = Mutex() - fun getWorkRequest(auth: SyncAuth): OneTimeWorkRequest { + fun getWorkRequest( + auth: SyncAuth, + networkType: NetworkType = MeteredSyncPolicy.getNetworkTypeRequiredForSync(forMedia = true), + id: UUID = UUID.randomUUID(), + ): OneTimeWorkRequest { val constraints = Constraints .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val data = - Data - .Builder() - .putString(HKEY_KEY, auth.hkey) - .putString(ENDPOINT_KEY, auth.endpoint) + .setRequiredNetworkType(networkType) .build() return OneTimeWorkRequestBuilder() - .setInputData(data) + .setId(id) + .setInputData(auth.toWorkData()) .setConstraints(constraints) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() } - fun start( + /** + * A later metered approval must also release media already waiting for Wi-Fi. + * Updating the existing request preserves any sync that has started in the meantime. + */ + suspend fun start( context: Context, auth: SyncAuth, + networkType: NetworkType = MeteredSyncPolicy.getNetworkTypeRequiredForSync(forMedia = true), ) { - Timber.i("Launching background media sync") - val request = getWorkRequest(auth) + schedulingLock.withLock { + Timber.i("Launching background media sync") + val request = getWorkRequest(auth, networkType) + + val manager = WorkManager.getInstance(context) + manager.enqueueUniqueWork(UniqueWorkNames.SYNC_MEDIA, ExistingWorkPolicy.KEEP, request).result.await() + if (networkType == NetworkType.CONNECTED) { + manager.allowMeteredMediaSync(auth) + } + } + } - WorkManager - .getInstance(context) - .enqueueUniqueWork(UniqueWorkNames.SYNC_MEDIA, ExistingWorkPolicy.KEEP, request) + /** + * Applies a new request's permission to use metered networks to retained media work. + * + * If a new request allows metered networks, it does not overwrite the old work due to + * [ExistingWorkPolicy.KEEP], so explicitly update the work request. + * + * Call after `enqueueUniqueWork` has completed while holding [schedulingLock]. + */ + private suspend fun WorkManager.allowMeteredMediaSync(auth: SyncAuth) { + val existing = + getWorkInfosForUniqueWork(UniqueWorkNames.SYNC_MEDIA).await().firstOrNull { + !it.state.isFinished && it.constraints.requiredNetworkType == NetworkType.UNMETERED + } ?: return + val result = updateWork(getWorkRequest(auth, NetworkType.CONNECTED, existing.id)).await() + Timber.i("Allowing metered media sync for work %s: %s", existing.id, result) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt index 6cae7ab73c93..2247b215e391 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt @@ -13,14 +13,12 @@ import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo -import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters import anki.collection.Progress import anki.sync.SyncCollectionResponse -import anki.sync.syncAuth import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol @@ -32,6 +30,7 @@ import com.ichi2.anki.common.permissions.canPostNotifications import com.ichi2.anki.notifications.NotificationId import com.ichi2.anki.setLastSyncTimeToNow import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.SyncAuth import com.ichi2.anki.sync.syncCollection import com.ichi2.anki.utils.ext.trySetForeground @@ -39,9 +38,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import timber.log.Timber import kotlin.coroutines.cancellation.CancellationException +/** + * Held while a background collection sync runs. A profile switch holds it until + * the process dies, so a sync cannot start part way through a restart. + */ +val syncLock = Mutex() + /** * Syncs collection and media in the background. * @@ -59,7 +65,6 @@ class SyncWorker( context: Context, parameters: WorkerParameters, ) : CoroutineWorker(context, parameters) { - private val workManager = WorkManager.getInstance(context) private val cancelIntent = WorkManager.getInstance(context).createCancelPendingIntent(id) private val notificationManager: NotificationManagerCompat? = if (canPostNotifications(context)) { @@ -70,18 +75,13 @@ class SyncWorker( override suspend fun doWork(): Result { Timber.v("SyncWorker::doWork") + return syncLock.withLockUnlessSwitching { doWorkHoldingLock() } + } + + private suspend fun doWorkHoldingLock(): Result { trySetForeground(getForegroundInfo()) - val hkey = - inputData.getString(HKEY_KEY) - ?: return Result.failure() - val auth = - syncAuth { - this.hkey = hkey - inputData.getString(ENDPOINT_KEY)?.let { - endpoint = it - } - }.let(::SyncAuth) + val auth = inputData.toSyncAuth() ?: return Result.failure() val shouldSyncMedia = inputData.getBoolean(SYNC_MEDIA_KEY, false) try { @@ -129,7 +129,7 @@ class SyncWorker( val text = syncProgress.run { "$added\n$removed" } notify(getProgressNotification(text)) } - delay(SyncMediaWorker.NOTIFICATION_UPDATE_RATE_MS) + delay(SyncMediaWorker.NOTIFICATION_UPDATE_RATE) } } try { @@ -172,13 +172,9 @@ class SyncWorker( } } - private fun syncMedia(auth: SyncAuth) { + private suspend fun syncMedia(auth: SyncAuth) { Timber.i("Enqueuing SyncMediaWorker") - workManager.enqueueUniqueWork( - UniqueWorkNames.SYNC_MEDIA, - ExistingWorkPolicy.KEEP, - SyncMediaWorker.getWorkRequest(auth), - ) + SyncMediaWorker.start(applicationContext, auth) } override suspend fun getForegroundInfo(): ForegroundInfo { @@ -229,8 +225,6 @@ class SyncWorker( } companion object { - private const val HKEY_KEY = "hkey" - private const val ENDPOINT_KEY = "endpoint" private const val SYNC_MEDIA_KEY = "syncMedia" fun start( @@ -241,14 +235,13 @@ class SyncWorker( val constraints = Constraints .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiredNetworkType(MeteredSyncPolicy.getNetworkTypeRequiredForSync()) .build() val data = Data .Builder() - .putString(HKEY_KEY, syncAuth.hkey) - .putString(ENDPOINT_KEY, syncAuth.endpoint) + .putAll(syncAuth.toWorkData()) .putBoolean(SYNC_MEDIA_KEY, syncMedia) .build() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt new file mode 100644 index 000000000000..f0cbf9796ca2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.worker + +import androidx.work.ListenableWorker.Result +import kotlinx.coroutines.sync.Mutex +import timber.log.Timber + +/** + * Runs [block] holding this lock. If a profile switch holds it, the work is dropped + * rather than rescheduled, since it carries the AnkiWeb key of the profile being left. + */ +internal suspend fun Mutex.withLockUnlessSwitching(block: suspend () -> Result): Result { + if (!tryLock()) { + Timber.i("Background work dropped: a profile switch is restarting the app") + return Result.failure() + } + try { + return block() + } finally { + unlock() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/customtabs/CustomTabActivityHelper.kt b/AnkiDroid/src/main/java/com/ichi2/compat/customtabs/CustomTabActivityHelper.kt index 47a71c76152f..a8a3c52e746e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/customtabs/CustomTabActivityHelper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/customtabs/CustomTabActivityHelper.kt @@ -9,62 +9,71 @@ import android.content.Context import android.net.Uri import android.os.Bundle import androidx.annotation.CheckResult +import androidx.annotation.UiThread import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsServiceConnection import androidx.browser.customtabs.CustomTabsSession import androidx.core.content.pm.PackageInfoCompat +import androidx.core.net.toUri import com.ichi2.anki.CommonString import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.compat.CompatHelper.Companion.getPackageInfoCompat import com.ichi2.anki.compat.PackageInfoFlagsCompat import com.ichi2.anki.snackbar.showSnackbar +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber /** * This is a helper class to manage the connection to the Custom Tabs Service. + * @param scope the activity's main-thread lifecycle scope */ -class CustomTabActivityHelper : ServiceConnectionCallback { +@UiThread +class CustomTabActivityHelper( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ServiceConnectionCallback { private var customTabsSession: CustomTabsSession? = null private var client: CustomTabsClient? = null private var connection: CustomTabsServiceConnection? = null private var customTabsProviderInfo: String? = null + private var initializationJob: Job? = null /** * Unbinds the Activity from the Custom Tabs Service. * @param activity the activity that is connected to the service. */ fun unbindCustomTabsService(activity: Activity) { - if (connection == null) return - connection.let { activity.unbindService(it!!) } - client = null - customTabsSession = null + onServiceDisconnected() + connection?.let { activity.unbindService(it) } connection = null customTabsProviderInfo = null } /** - * Creates or retrieves an exiting CustomTabsSession. + * Returns the prepared session, or `null` if none is available. * - * @return a CustomTabsSession. + * Custom tabs can open without a session, so do not wait for this if it is unavailable. */ val session: CustomTabsSession? - get() { - if (client == null) { - customTabsSession = null - } else if (customTabsSession == null) { - customTabsSession = client!!.newSession(null) - } - return customTabsSession - } + get() = customTabsSession /** * Binds the Activity to the Custom Tabs Service. * @param activity the activity to be bound to the service. */ fun bindCustomTabsService(activity: Activity) { - if (client != null) return + if (connection != null) return customTabsProviderInfo = null try { val packageName = CustomTabsHelper.getPackageNameToUse(activity) ?: return @@ -88,7 +97,7 @@ class CustomTabActivityHelper : ServiceConnectionCallback { sCustomTabsFailed = true client = null customTabsSession = null - connection = null + // connection should be set to null in `onStop` customTabsProviderInfo = null } @@ -106,47 +115,78 @@ class CustomTabActivityHelper : ServiceConnectionCallback { } /** + * Preloads a URL for future loading. + * * @see CustomTabsSession.mayLaunchUrl - * @return true if call to mayLaunchUrl was accepted. */ fun mayLaunchUrl( - uri: Uri?, - extras: Bundle?, - otherLikelyBundles: List?, - ): Boolean { - if (client == null) return false - val session = session ?: return false - return session.mayLaunchUrl(uri, extras, otherLikelyBundles) + url: String, + extras: Bundle? = null, + otherLikelyBundles: List? = null, + ) { + scope.launch { + // must be on the main thread + val cachedSession = session + val success = + withContext(ioDispatcher) { + cachedSession?.mayLaunchUrl(url.toUri(), extras, otherLikelyBundles) == true + } + if (!success) { + Timber.w("Couldn't preload url: %s", url) + } + } } override fun onServiceConnected(client: CustomTabsClient) { - try { - this.client = client - try { - this.client!!.warmup(0L) - } catch (e: IllegalStateException) { - // Issue 5337 - some browsers like TorBrowser don't adhere to Android 8 background limits - // They will crash as they attempt to start services. warmup failure shouldn't be fatal though. - Timber.w(e, "Ignoring CustomTabs implementation that doesn't conform to Android 8 background limits") + onServiceDisconnected() + this.client = client + initializationJob = + scope.launch { + try { + customTabsSession = + withContext(ioDispatcher) { + warmup(client) + // Cancelling cannot interrupt Binder IPC. Don't start another call after stopping. + ensureActive() + client.newSession(null) + } + } catch (e: CancellationException) { + throw e + } catch (e: RuntimeException) { + // A late failure from a cancelled connection must not disable its replacement. + currentCoroutineContext().ensureActive() + handleInitializationFailure(e) + } } - session - } catch (e: RuntimeException) { - // #6142 - A securityException here means that we're not able to load the CustomTabClient at all, whereas - // the IllegalStateException was a failure, but could be continued from - Timber.w(e, "CustomTabsService bind attempt failed, using fallback. %s", customTabsProviderInfo) - CrashReportService.sendExceptionReport( - e = e, - origin = "CustomTabActivityHelper::onServiceConnected", - additionalInfo = customTabsProviderInfo, - onlyIfSilent = true, - ) - // TODO: https://github.com/ankidroid/Anki-Android/issues/21708 - // Edge throws on a cold bind. Retry in the future instead of disabling the feature - disableCustomTabHandler() + } + + @WorkerThread + private fun warmup(client: CustomTabsClient) { + try { + client.warmup(0L) + } catch (e: IllegalStateException) { + // Issue 5337 - some browsers don't adhere to Android 8 background limits. + // Warmup failure shouldn't be fatal. + Timber.w(e, "Ignoring CustomTabs implementation that doesn't conform to Android 8 background limits") } } + private fun handleInitializationFailure(e: RuntimeException) { + Timber.w(e, "CustomTabsService bind attempt failed, using fallback. %s", customTabsProviderInfo) + CrashReportService.sendExceptionReport( + e = e, + origin = "CustomTabActivityHelper::onServiceConnected", + additionalInfo = customTabsProviderInfo, + onlyIfSilent = true, + ) + // TODO: https://github.com/ankidroid/Anki-Android/issues/21708 + // Edge throws on a cold bind. Retry in the future instead of disabling the feature + disableCustomTabHandler() + } + override fun onServiceDisconnected() { + initializationJob?.cancel() + initializationJob = null client = null customTabsSession = null } diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/ui/components/HeroToolbarActionButton.kt b/AnkiDroid/src/main/java/com/ichi2/compose/ui/components/HeroToolbarActionButton.kt new file mode 100644 index 000000000000..b690b9ed2c1b --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/compose/ui/components/HeroToolbarActionButton.kt @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.compose.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.ichi2.anki.CommonString +import com.ichi2.compose.ui.preview.AnkiDroidPreview +import com.ichi2.compose.ui.preview.ThemePreviews + +/** A prominent primary action for a toolbar. */ +@Composable +fun HeroToolbarActionButton( + modifier: Modifier = Modifier, + label: String = stringResource(CommonString.multimedia_editor_field_editing_done), + onClick: () -> Unit = {}, +) { + Box(modifier.padding(horizontal = 8.dp)) { + Button(onClick = onClick, modifier = Modifier.heightIn(min = 48.dp)) { + Text(text = label) + } + } +} + +@ThemePreviews +@Composable +private fun HeroToolbarActionButtonPreview() { + AnkiDroidPreview { + HeroToolbarActionButton() + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt new file mode 100644 index 000000000000..55dd0abba7c5 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.compose.ui.preview + +import android.view.ContextThemeWrapper +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.ichi2.anki.R +import com.ichi2.compose.theme.AnkiDroidTheme + +/** + * Applies the AnkiDroid theme matching the preview's light or dark UI mode. + * + * Use this wrapper with [ThemePreviews]. + */ +@Composable +fun AnkiDroidPreview(content: @Composable () -> Unit) { + // AnkiDroidTheme reads XML attributes, so changing only the preview's UI mode is insufficient. + val context = LocalContext.current + val theme = if (isSystemInDarkTheme()) R.style.Theme_Dark else R.style.Theme_Light + val themedContext = remember(context, theme) { ContextThemeWrapper(context, theme) } + CompositionLocalProvider(LocalContext provides themedContext) { + AnkiDroidTheme(content = content) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/ThemePreviews.kt b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/ThemePreviews.kt index 4d4543793aa5..c93f2c14f0c6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/ThemePreviews.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/ThemePreviews.kt @@ -21,4 +21,10 @@ import androidx.compose.ui.tooling.preview.Preview uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true, ) +@Preview( + name = "Dark", + group = "theme", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) annotation class ThemePreviews diff --git a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt index 38f683c269c4..cf8e5e2ced30 100644 --- a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.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.imagecropper diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/DialogFragmentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/DialogFragmentProvider.kt index 69a6e2858607..59e9f06de422 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/DialogFragmentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/DialogFragmentProvider.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.preferences diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/HtmlHelpPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/HtmlHelpPreference.kt index de145631b981..43677e5084c3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/HtmlHelpPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/HtmlHelpPreference.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.preferences diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/IncrementerNumberRangePreferenceCompat.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/IncrementerNumberRangePreferenceCompat.kt index 2f1b5ba0e4ca..12b830c1bedf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/IncrementerNumberRangePreferenceCompat.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/IncrementerNumberRangePreferenceCompat.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2021 Tushar Bhatt - * - * 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 Tushar Bhatt package com.ichi2.preferences diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/NumberRangePreferenceCompat.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/NumberRangePreferenceCompat.kt index 10d24785b5f1..0c09037c08e6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/NumberRangePreferenceCompat.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/NumberRangePreferenceCompat.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2013 Houssam Salem - * - * 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) 2013 Houssam Salem package com.ichi2.preferences diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/TextWidgetPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/TextWidgetPreference.kt index 5e8ed8c7f6ff..cb2b880b203c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/TextWidgetPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/TextWidgetPreference.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.preferences diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/VersatileTextPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/VersatileTextPreference.kt index 9c12172ba81d..31e890b3d063 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/VersatileTextPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/VersatileTextPreference.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.preferences diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/VersatileTextWithASwitchPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/VersatileTextWithASwitchPreference.kt index 07ffc804b5f8..34e1daef2f8e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/VersatileTextWithASwitchPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/VersatileTextWithASwitchPreference.kt @@ -1,16 +1,4 @@ -/* - * 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 package com.ichi2.preferences diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/AccessibleSearchView.kt b/AnkiDroid/src/main/java/com/ichi2/ui/AccessibleSearchView.kt index 5936b87a1df4..979feb013a5c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/ui/AccessibleSearchView.kt +++ b/AnkiDroid/src/main/java/com/ichi2/ui/AccessibleSearchView.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2024 Arthur Milchior - * - * 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 Arthur Milchior package com.ichi2.ui diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/CheckBoxTriStates.kt b/AnkiDroid/src/main/java/com/ichi2/ui/CheckBoxTriStates.kt index 47bdffe7b6b0..de8b05fe676f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/ui/CheckBoxTriStates.kt +++ b/AnkiDroid/src/main/java/com/ichi2/ui/CheckBoxTriStates.kt @@ -1,18 +1,6 @@ -/* - Copyright (c) 2021 Tarek Mohamed Abdalla +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Tarek Mohamed Abdalla - 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.ui import android.content.Context diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/RtlCompliantActionProvider.kt b/AnkiDroid/src/main/java/com/ichi2/ui/RtlCompliantActionProvider.kt index f9cbec6efadd..06d786d77dd8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/ui/RtlCompliantActionProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/ui/RtlCompliantActionProvider.kt @@ -1,15 +1,6 @@ -/* - Copyright (c) 2021 Tarek Mohamed Abdalla - 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 Tarek Mohamed Abdalla + package com.ichi2.ui import android.app.Activity diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/BitmapUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/BitmapUtil.kt index d8fe08618eef..6c02ad33508c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/BitmapUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/BitmapUtil.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.utils diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/Colour.kt b/AnkiDroid/src/main/java/com/ichi2/utils/Colour.kt index fe6520c176da..7ec5829b2c82 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/Colour.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/Colour.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2023 Arthur Milchior - * - * 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 Arthur Milchior package com.ichi2.utils diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt index e69cc4aa4dc1..50ff13edf07c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt @@ -2,13 +2,14 @@ package com.ichi2.utils +import android.app.Dialog import android.content.Context import android.graphics.Insets import android.graphics.Point import android.os.Build -import android.view.Window import android.view.WindowInsets import android.view.WindowManager +import com.ichi2.anki.compat.CompatHelper object DisplayUtils { @Suppress("DEPRECATION") // #9333: defaultDisplay & getSize @@ -37,10 +38,16 @@ object DisplayUtils { return getDisplayDimensions(wm) } - /** Allow the window to be resized when an input method is shown, - * so that its contents are not covered by the input method */ - @Suppress("DEPRECATION") // 7110: SOFT_INPUT_ADJUST_RESIZE - fun resizeWhenSoftInputShown(window: Window) { - window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + /** + * Fit a floating dialog above the keyboard, or pan to its focused input when [resize] is false. + * + * Activities with edge-to-edge content should handle IME insets in their own layout. + */ + fun setDialogKeyboardResize( + dialog: Dialog, + resize: Boolean = true, + ) { + val window = dialog.window ?: return + CompatHelper.compat.setDialogKeyboardResize(window, resize) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ExifUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ExifUtil.kt index 659c9158b553..2a015a473494 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ExifUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ExifUtil.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2013 Bibek Shrestha - * Copyright (c) 2013 Zaur Molotnikov - * Copyright (c) 2013 Nicolas Raoul - * Copyright (c) 2013 Flavio Lerda - * - * 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) 2013 Bibek Shrestha +// SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov +// SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Raoul +// SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda package com.ichi2.utils diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ExtendedFragmentFactory.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ExtendedFragmentFactory.kt index a42441e1f157..d416e5a00faa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ExtendedFragmentFactory.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ExtendedFragmentFactory.kt @@ -1,18 +1,6 @@ -/* - Copyright (c) 2021 Tarek Mohamed Abdalla +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Tarek Mohamed Abdalla - 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.utils import androidx.annotation.CallSuper diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/FragmentFactoryUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/FragmentFactoryUtils.kt index 86d000526ef9..a9b53584f629 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/FragmentFactoryUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/FragmentFactoryUtils.kt @@ -1,18 +1,6 @@ -/* - Copyright (c) 2021 Tarek Mohamed +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Tarek Mohamed - 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.utils import androidx.fragment.app.Fragment diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ImportResolution.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ImportResolution.kt new file mode 100644 index 000000000000..007a05b33ed6 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ImportResolution.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.utils + +import android.net.Uri + +/** Metadata resolved once from an import intent, before copying or opening import UI. */ +sealed class ImportResolution { + data class Source( + val uri: Uri, + val fileName: String, + ) + + sealed class ResolvedFile( + open val source: Source, + ) : ImportResolution() + + data class DeckPackage( + override val source: Source, + ) : ResolvedFile(source) + + data class CollectionPackage( + override val source: Source, + ) : ResolvedFile(source) + + data class Text( + override val source: Source, + ) : ResolvedFile(source) + + data class Failure( + val error: ImportResult.Failure, + ) : ImportResolution() +} diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt index 0afc66b6aadb..b5443720bb4c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt @@ -33,6 +33,8 @@ import com.ichi2.anki.onSelectedCsvForImport import com.ichi2.anki.servicelayer.DebugInfoService import com.ichi2.anki.showImportDialog import com.ichi2.anki.ui.internationalization.sentenceCase +import com.ichi2.anki.utils.MimeTypeUtils +import com.ichi2.utils.IntentUtil.resolveMimeType import kotlinx.coroutines.launch import org.jetbrains.annotations.Contract import timber.log.Timber @@ -47,6 +49,12 @@ object ImportUtils { // A filename should be shortened if over this threshold private const val FILE_NAME_SHORTENING_THRESHOLD = 100 + /** Resolve the source and select its importer without copying files or starting UI. */ + fun resolveImport( + context: Context, + intent: Intent, + ): ImportResolution = FileImporter().resolveImport(context, intent) + /** * This code is used in multiple places to handle package imports * @@ -102,18 +110,61 @@ object ImportUtils { uri: Uri, ): Boolean { val mimeType = context.contentResolver.getType(uri) - return mimeType in - listOf( - "text/plain", - "text/comma-separated-values", - "text/tab-separated-values", - "text/csv", - "text/tsv", - ) + return mimeType in MimeTypeUtils.CSV_TSV_MIME_TYPES } @SuppressWarnings("WeakerAccess") open class FileImporter { + fun resolveImport( + context: Context, + intent: Intent, + ): ImportResolution = + try { + resolveImportInternal(context, intent) + } catch (e: Exception) { + Timber.w(e, "Could not resolve import source") + ImportResolution.Failure( + ImportResult.Failure( + humanReadableMessage = context.getString(CommonString.import_error_handle_exception, e.localizedMessage), + exception = e, + ), + ) + } + + private fun resolveImportInternal( + context: Context, + intent: Intent, + ): ImportResolution { + val uri = + getDataUri(intent) + ?: return ImportResolution.Failure(ImportResult.Failure(context.getString(CommonString.import_log_no_apkg))) + val providedName = + getFileNameFromContentProvider(context, uri) + ?: when (intent.type) { + "application/apkg", "application/zip" -> "unknown_filename.apkg" + else -> return ImportResolution.Failure( + ImportResult.Failure( + context.getString(CommonString.import_error_content_provider, AnkiDroidApp.manualUrl + "#importing"), + ), + ) + } + val filename = validateFileName(providedName) + val source = ImportResolution.Source(uri, filename) + return when { + getExtension(filename).lowercase() in listOf("csv", "tsv", "txt") || + intent.resolveMimeType() in MimeTypeUtils.CSV_TSV_MIME_TYPES || isValidTextOrDataFile(context, uri) -> + ImportResolution.Text(source) + isCollectionPackage(filename) -> ImportResolution.CollectionPackage(source) + isDeckPackage(filename) -> ImportResolution.DeckPackage(source) + isAnkiDatabase(filename) -> + ImportResolution.Failure(ImportResult.Failure(context.getString(CommonString.import_error_load_imported_database))) + else -> + ImportResolution.Failure( + ImportResult.Failure(context.getString(CommonString.import_error_not_apkg_extension, filename)), + ) + } + } + /** * This code is used in multiple places to handle package imports * @@ -193,56 +244,20 @@ object ImportUtils { importPathUri: Uri, intent: Intent? = null, ): ImportResult { - // Note: intent.getData() can be null. Use data instead. - if (!isValidImportType(context, importPathUri)) { - return ImportResult.Failure(context.getString(CommonString.import_log_no_apkg)) - } - // Get the original filename from the content provider URI - var filename = getFileNameFromContentProvider(context, importPathUri) - // Hack to fix bug where ContentResolver not returning filename correctly - if (filename == null) { - when (intent?.type) { - "application/apkg", "application/zip" -> { - // Set a dummy filename if MIME type provided or is a valid zip file - filename = "unknown_filename.apkg" - Timber.w("Could not retrieve filename from ContentProvider, but was valid zip file so we try to continue") - } - - else -> { - Timber.e("Could not retrieve filename from ContentProvider") - CrashReportService.sendExceptionReport( - RuntimeException("Could not import apkg from ContentProvider"), - "IntentHandler.java", - "apkg import failed; mime type ${intent?.type}", - ) - return ImportResult.Failure( - AnkiDroidApp.appResources.getString( - CommonString.import_error_content_provider, - AnkiDroidApp.manualUrl + "#importing", - ), - ) - } + val file = + when (val resolution = resolveImport(context, intent ?: Intent().setData(importPathUri))) { + is ImportResolution.Failure -> return resolution.error + is ImportResolution.ResolvedFile -> resolution } - } - if (isValidTextOrDataFile(context, importPathUri)) { - (context as Activity).onSelectedCsvForImport(intent!!) + if (file is ImportResolution.Text) { + (context as Activity).onSelectedCsvForImport(intent ?: Intent().setData(importPathUri)) return ImportResult.Success - } else if (!isValidPackageName(filename)) { - return if (isAnkiDatabase(filename)) { - // .anki2 files aren't supported by Anki Desktop, we should eventually support them, because we can - // but for now, show a "nice" error. - ImportResult.Failure(context.resources.getString(CommonString.import_error_load_imported_database)) - } else { - // Don't import if file doesn't have an Anki package extension - ImportResult.Failure(context.resources.getString(CommonString.import_error_not_apkg_extension, filename)) - } } // Copy to temporary file - filename = validateFileName(filename) val checkFile = try { - context.cacheDir.withFileNameSafe(filename) + context.cacheDir.withFileNameSafe(file.source.fileName) } catch (_: SecurityException) { // Do not log the exception: it may interpolate attacker-controlled paths (PII). Timber.e("Path traversal detected in handleContentProviderFile") @@ -254,7 +269,7 @@ object ImportUtils { } val tempOutDir: String = Uri.fromFile(checkFile).encodedPath!! - copyFileToCache(context, importPathUri, tempOutDir).asErrorDetails()?.let { details -> + copyFileToCache(context, file.source.uri, tempOutDir).asErrorDetails()?.let { details -> details.exceptionForReport?.let { CrashReportService.sendExceptionReport(it, "ImportUtils") } return ImportResult.Failure( title = details.buildTitle(context), @@ -342,7 +357,7 @@ object ImportUtils { ).use { cursor -> if (cursor != null && cursor.moveToFirst()) { filename = cursor.getString(0) - Timber.d("handleFileImport() Importing from content provider: %s", filename) + Timber.d("Resolved import filename from content provider: %s", filename) } } } catch (e: Exception) { diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt index b76722010e02..591755dcc02a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt @@ -9,10 +9,12 @@ import androidx.annotation.StringRes import androidx.core.os.ConfigurationCompat import androidx.fragment.app.Fragment import com.ichi2.anki.CommonString +import com.ichi2.anki.backend.AnkiBackendLanguage import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.compat.CompatHelper import net.ankiweb.rsdroid.BackendFactory +import timber.log.Timber import java.util.Locale /** @@ -313,7 +315,10 @@ object LanguageUtil { fun getSystemLocale(): Locale = getLocaleCompat(Resources.getSystem())!! - /** If locale is not provided, the current locale will be used. */ + /** + * If locale is not provided, the current locale will be used. + * If conversion fails, passes the original tag to rslib for its own parsing and language fallback. + */ fun setDefaultBackendLanguages(languageTag: String? = null) { val langCode = languageTag ?: appContext @@ -326,17 +331,12 @@ object LanguageUtil { } else { langCode } - BackendFactory.defaultLanguages = listOf(languageTagToBackendCode(localeLanguage)) - } - - private fun languageTagToBackendCode(languageTag: String): String = - when (languageTag) { - "heb" -> "he" - "ind" -> "id" - "tgl" -> "tl" - "hi" -> "hi-IN" - else -> languageTag + val backendLanguage = AnkiBackendLanguage.fromLanguageTag(localeLanguage) + if (backendLanguage == null) { + Timber.w("Could not normalize language tag '%s'; passing it to rslib unchanged", localeLanguage) } + BackendFactory.defaultLanguages = listOf(backendLanguage?.value ?: localeLanguage) + } /** @return string defined with [stringRes] on the specified [locale] */ fun Context.getStringByLocale( diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/NoteFieldDecorator.kt b/AnkiDroid/src/main/java/com/ichi2/utils/NoteFieldDecorator.kt index 9bc23e067d3e..c4a39769b9e6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/NoteFieldDecorator.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/NoteFieldDecorator.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2020 Mike Hardy - * - * 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) 2020 Mike Hardy @file:Suppress("SpellCheckingInspection") diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt b/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt index fac9135b3903..ebbb3397a5bd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt @@ -33,7 +33,7 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.windows.permissions.PermissionsBottomSheet import com.ichi2.utils.Permissions.arePermissionsDefinedInManifest import timber.log.Timber -import kotlin.reflect.KMutableProperty +import kotlin.reflect.KMutableProperty0 object Permissions { @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -52,9 +52,9 @@ object Permissions { fun canPermissionBeRequested( activity: Activity, permission: String, - permissionRequestedFlag: KMutableProperty, + permissionRequestedFlag: KMutableProperty0, ): Boolean = - if (permissionRequestedFlag.getter.call()) { + if (permissionRequestedFlag.get()) { // Previous requests have been made, only request again if we are not blocked from requesting ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) } else { @@ -79,11 +79,11 @@ object Permissions { fun Fragment.requestPermissionThroughDialogOrSettings( activity: Activity, permission: String, - permissionRequestedFlag: KMutableProperty, + permissionRequestedFlag: KMutableProperty0, permissionRequestLauncher: ActivityResultLauncher, ) { if (canPermissionBeRequested(activity, permission, permissionRequestedFlag)) { - permissionRequestedFlag.setter.call(true) + permissionRequestedFlag.set(true) permissionRequestLauncher.launch(permission) } else { showToastAndOpenAppSettingsScreenForPermission(permission, CommonString.manually_grant_permissions) diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt index b8c5edbda662..04faba4815c3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt @@ -1,18 +1,5 @@ -/* - Copyright (c) 2021 Tarek Mohamed Abdalla - - 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 Tarek Mohamed Abdalla package com.ichi2.utils diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt b/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt index 80d940541704..d80ba5fefbaf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt @@ -1,18 +1,5 @@ -/* - Copyright (c) 2021 Tarek Mohamed Abdalla - - 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 Tarek Mohamed Abdalla @file:Suppress( "ktlint:standard:discouraged-comment-location", diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/VersionUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/VersionUtils.kt index 2e0da0d46c17..03ad5e777259 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/VersionUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/VersionUtils.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2015 Timothy Rae - * - * 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) 2015 Timothy Rae package com.ichi2.utils diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt b/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt index 917563f462eb..3d9126524ac1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt @@ -21,7 +21,6 @@ import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.edit import com.ichi2.anki.CommonPlurals -import com.ichi2.anki.R import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.destinations.DeferredNavigation import com.ichi2.anki.common.destinations.LauncherDestination @@ -29,6 +28,7 @@ import com.ichi2.anki.common.destinations.toIntent import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.compat.CompatHelper.Companion.registerReceiverCompat +import com.ichi2.anki.widgets.R import timber.log.Timber import kotlin.math.sqrt diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/DayRolloverAlarm.kt b/AnkiDroid/src/main/java/com/ichi2/widget/DayRolloverAlarm.kt index d308104548c0..e614de115418 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/DayRolloverAlarm.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/DayRolloverAlarm.kt @@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting import androidx.core.app.PendingIntentCompat import anki.collection.opChanges import com.ichi2.anki.CollectionManager.withOpenColOrNull +import com.ichi2.anki.common.android.AlarmManagement import com.ichi2.anki.common.android.AnkiBroadcastReceiver import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService @@ -21,7 +22,6 @@ import com.ichi2.anki.launchCatching import com.ichi2.anki.libanki.EpochMilliseconds import com.ichi2.anki.libanki.sched.Scheduler import com.ichi2.anki.observability.ChangeManager -import com.ichi2.utils.AlarmManagement import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import timber.log.Timber diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt index 8e65a91109e2..06ead1aa50d1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt @@ -12,7 +12,6 @@ import android.content.Intent import android.view.View import android.widget.RemoteViews import com.ichi2.anki.CommonString -import com.ichi2.anki.R import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination @@ -22,6 +21,7 @@ import com.ichi2.anki.common.destinations.toIntent import com.ichi2.anki.isCollectionEmpty import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Decks.Companion.NOT_FOUND_DECK_ID +import com.ichi2.anki.widgets.R import com.ichi2.widget.ACTION_UPDATE_WIDGET import com.ichi2.widget.AnalyticsWidgetProvider import com.ichi2.widget.AppWidgetId @@ -33,6 +33,7 @@ import com.ichi2.widget.cancelRecurringAlarm import com.ichi2.widget.deckpicker.DeckWidgetData import com.ichi2.widget.deckpicker.getDeckNameAndStats import com.ichi2.widget.getAppWidgetIdsEx +import com.ichi2.widget.getAppWidgetManager import com.ichi2.widget.setRecurringAlarm import com.ichi2.widget.updateAppWidget import kotlinx.coroutines.launch @@ -204,7 +205,7 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() { * It fetches the App Widget IDs and updates each widget with the associated deck ID. */ fun updateCardAnalysisWidgets(context: Context) { - val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetManager = getAppWidgetManager(context) ?: return val provider = ComponentName(context, CardAnalysisWidget::class.java) Timber.d("Fetching appWidgetIds for provider: ${provider.shortClassName}") @@ -260,14 +261,13 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() { context: Context, intent: Intent, ) { + val appWidgetManager = getAppWidgetManager(context) ?: return super.onReceive(context, intent) val widgetPreferences = CardAnalysisWidgetPreferences(context) when (intent.action) { ACTION_APPWIDGET_UPDATE -> { - val appWidgetManager = AppWidgetManager.getInstance(context) - // Retrieve the widget ID from the intent val appWidgetId = intent.getAppWidgetId() val selectedDeckId = intent.getLongExtra(EXTRA_SELECTED_DECK_ID, -1L) @@ -290,7 +290,7 @@ class CardAnalysisWidget : AnalyticsWidgetProvider() { Timber.d("Received ACTION_UPDATE_WIDGET for widget ID: $appWidgetId") // Update the widget using the internally fetched deck ID - updateWidget(context, AppWidgetManager.getInstance(context), appWidgetId) + updateWidget(context, appWidgetManager, appWidgetId) } } AppWidgetManager.ACTION_APPWIDGET_OPTIONS_CHANGED -> { diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt index 5afa41f95a1a..735464be36df 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt @@ -19,11 +19,9 @@ import androidx.core.view.updateMargins import com.ichi2.anki.AnkiActivity import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString -import com.ichi2.anki.R import com.ichi2.anki.common.android.AnkiBroadcastReceiver import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.ext.unregisterReceiverSilently -import com.ichi2.anki.databinding.ActivityCardAnalysisWidgetConfigBinding import com.ichi2.anki.dialogs.registerDeckSelectedHandler import com.ichi2.anki.dialogs.startDeckSelection import com.ichi2.anki.isCollectionEmpty @@ -31,10 +29,13 @@ import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.model.SelectableDeck import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.anki.ui.internationalization.sentenceCase +import com.ichi2.anki.widgets.R +import com.ichi2.anki.widgets.databinding.ActivityCardAnalysisWidgetConfigBinding import com.ichi2.anki.withProgress import com.ichi2.widget.AppWidgetId.Companion.INVALID_APPWIDGET_ID import com.ichi2.widget.AppWidgetId.Companion.getAppWidgetId import com.ichi2.widget.cardanalysis.CardAnalysisWidget.Companion.EXTRA_SELECTED_DECK_ID +import com.ichi2.widget.getAppWidgetManager import dev.androidbroadcast.vbpd.viewBinding import timber.log.Timber @@ -168,6 +169,7 @@ class CardAnalysisWidgetConfig : AnkiActivity(R.layout.activity_card_analysis_wi } private fun updateWidget() { + val appWidgetManager = getAppWidgetManager(this) ?: return val updateIntent = Intent(this, CardAnalysisWidget::class.java).apply { action = AppWidgetManager.ACTION_APPWIDGET_UPDATE @@ -177,7 +179,6 @@ class CardAnalysisWidgetConfig : AnkiActivity(R.layout.activity_card_analysis_wi sendBroadcast(updateIntent) - val appWidgetManager = AppWidgetManager.getInstance(this) CardAnalysisWidget.updateWidget(this, appWidgetManager, appWidgetId) } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt index c45e67b3e13d..844d362ddf71 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt @@ -18,9 +18,7 @@ import android.view.View.MeasureSpec import android.widget.RemoteViews import android.widget.TextView import androidx.core.os.BundleCompat -import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonString -import com.ichi2.anki.R import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination @@ -28,7 +26,7 @@ import com.ichi2.anki.common.destinations.DeferredNavigation import com.ichi2.anki.common.destinations.ReviewDeckDestination import com.ichi2.anki.common.destinations.toIntent import com.ichi2.anki.isCollectionEmpty -import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.widgets.R import com.ichi2.widget.ACTION_UPDATE_WIDGET import com.ichi2.widget.AnalyticsWidgetProvider import com.ichi2.widget.AppWidgetId @@ -38,29 +36,13 @@ import com.ichi2.widget.AppWidgetIds import com.ichi2.widget.DayRolloverAlarm import com.ichi2.widget.cancelRecurringAlarm import com.ichi2.widget.getAppWidgetIdsEx +import com.ichi2.widget.getAppWidgetManager import com.ichi2.widget.setRecurringAlarm import com.ichi2.widget.updateAppWidget import kotlinx.coroutines.launch import timber.log.Timber import kotlin.math.roundToInt -/** - * Data class representing the data for a deck displayed in the widget. - * - * @property deckId The ID of the deck. - * @property name The name of the deck. - * @property reviewCount The number of cards due for review. - * @property learnCount The number of cards in the learning phase. - * @property newCount The number of new cards. - */ -data class DeckWidgetData( - val deckId: DeckId, - val name: String, - val reviewCount: Int, - val learnCount: Int, - val newCount: Int, -) - /** * This widget displays a list of decks with their respective new, learning, and review card counts. * It updates every minute. @@ -301,7 +283,7 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { * It fetches the App Widget IDs and updates each widget with the associated deck IDs. */ fun updateDeckPickerWidgets(context: Context) { - val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetManager = getAppWidgetManager(context) ?: return val provider = ComponentName(context, DeckPickerWidget::class.java) Timber.d("Fetching appWidgetIds for provider: ${provider.shortClassName}") @@ -367,14 +349,13 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { context: Context, intent: Intent, ) { + val appWidgetManager = getAppWidgetManager(context) ?: return super.onReceive(context, intent) val widgetPreferences = DeckPickerWidgetPreferences(context) when (intent.action) { ACTION_APPWIDGET_UPDATE -> { - val appWidgetManager = AppWidgetManager.getInstance(context) - // Retrieve the widget ID from the intent val appWidgetId = intent.getAppWidgetId() val selectedDeckIds = intent.getLongArrayExtra(EXTRA_SELECTED_DECK_IDS) @@ -415,7 +396,7 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { selectedDeckIds.joinToString(", ") }", ) - updateWidget(context, AppWidgetManager.getInstance(context), appWidgetId, selectedDeckIds) + updateWidget(context, appWidgetManager, appWidgetId, selectedDeckIds) } AppWidgetManager.ACTION_APPWIDGET_DELETED -> { Timber.d("ACTION_APPWIDGET_DELETED received") @@ -465,35 +446,3 @@ class DeckPickerWidget : AnalyticsWidgetProvider() { } } } - -/** - * Map deck id to the associated DeckPickerWidgetData. Omits any id that does not correspond to a deck. - * - * Note: This operation may be slow, as it involves processing the entire deck collection. - * - * @param deckId the list of deck ID to retrieve data for - * @return a list of DeckPickerWidgetData objects containing deck names and statistics - */ -suspend fun getDeckNameAndStats(deckId: DeckId): DeckWidgetData? = getDeckNamesAndStats(listOf(deckId)).getOrNull(0) - -suspend fun getDeckNamesAndStats(deckIds: List): List { - val result = mutableListOf() - - val deckTree = withCol { sched.deckDueTree() } - - deckTree.forEach { node -> - if (node.did !in deckIds) return@forEach - result.add( - DeckWidgetData( - deckId = node.did, - name = node.lastDeckNameComponent, - reviewCount = node.revCount, - learnCount = node.lrnCount, - newCount = node.newCount, - ), - ) - } - - val deckIdToData = result.associateBy { it.deckId } - return deckIds.mapNotNull { deckIdToData[it] } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt index 0cea9c030a18..f12999bf1f4f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt @@ -29,11 +29,9 @@ import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.AnkiActivity import com.ichi2.anki.CommonPlurals import com.ichi2.anki.CommonString -import com.ichi2.anki.R import com.ichi2.anki.common.android.AnkiBroadcastReceiver import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.ext.unregisterReceiverSilently -import com.ichi2.anki.databinding.WidgetDeckPickerConfigBinding import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.dialogs.DiscardChangesDialog import com.ichi2.anki.dialogs.registerDeckSelectedHandler @@ -45,11 +43,14 @@ import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.startup.ensureStorageIsReady +import com.ichi2.anki.widgets.R +import com.ichi2.anki.widgets.databinding.WidgetDeckPickerConfigBinding import com.ichi2.utils.dp import com.ichi2.widget.AppWidgetId.Companion.INVALID_APPWIDGET_ID import com.ichi2.widget.AppWidgetId.Companion.getAppWidgetId import com.ichi2.widget.AppWidgetId.Companion.updateWidget import com.ichi2.widget.WidgetConfigScreenAdapter +import com.ichi2.widget.getAppWidgetManager import dev.androidbroadcast.vbpd.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -270,8 +271,9 @@ class DeckPickerWidgetConfig : val selectedDeckIds = deckPickerWidgetPreferences.getSelectedDeckIdsFromPreferences(appWidgetId) - val appWidgetManager = AppWidgetManager.getInstance(this) - DeckPickerWidget.updateWidget(this, appWidgetManager, appWidgetId, selectedDeckIds) + getAppWidgetManager(this)?.let { appWidgetManager -> + DeckPickerWidget.updateWidget(this, appWidgetManager, appWidgetId, selectedDeckIds) + } val resultValue = Intent().updateWidget(appWidgetId) setResult(RESULT_OK, resultValue) diff --git a/AnkiDroid/src/main/res/drawable/baseline_content_copy_24.xml b/AnkiDroid/src/main/res/drawable/baseline_content_copy_24.xml index 784c39d196dc..c00b9db7b889 100644 --- a/AnkiDroid/src/main/res/drawable/baseline_content_copy_24.xml +++ b/AnkiDroid/src/main/res/drawable/baseline_content_copy_24.xml @@ -1,19 +1,9 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + SPDX-FileComment: Style: Filled + Source: https://fonts.gstatic.com/s/i/materialicons/content_copy/v17/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/baseline_download_for_offline_24.xml b/AnkiDroid/src/main/res/drawable/baseline_download_for_offline_24.xml index ba99267582c6..04d036f20634 100644 --- a/AnkiDroid/src/main/res/drawable/baseline_download_for_offline_24.xml +++ b/AnkiDroid/src/main/res/drawable/baseline_download_for_offline_24.xml @@ -1,19 +1,9 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + SPDX-FileComment: Style: Filled + Source: https://fonts.gstatic.com/s/i/materialicons/download_for_offline/v9/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/baseline_forward_5_24.xml b/AnkiDroid/src/main/res/drawable/baseline_forward_5_24.xml index f0d1fd313fd0..82efef35820b 100644 --- a/AnkiDroid/src/main/res/drawable/baseline_forward_5_24.xml +++ b/AnkiDroid/src/main/res/drawable/baseline_forward_5_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/baseline_offline_pin_24.xml b/AnkiDroid/src/main/res/drawable/baseline_offline_pin_24.xml index 37479bbbb776..a908c249132d 100644 --- a/AnkiDroid/src/main/res/drawable/baseline_offline_pin_24.xml +++ b/AnkiDroid/src/main/res/drawable/baseline_offline_pin_24.xml @@ -1,19 +1,9 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + SPDX-FileComment: Style: Filled + Source: https://fonts.gstatic.com/s/i/materialicons/offline_pin/v18/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/baseline_replay_5_24.xml b/AnkiDroid/src/main/res/drawable/baseline_replay_5_24.xml index ba3fa70cbf2f..d79cc0d5e191 100644 --- a/AnkiDroid/src/main/res/drawable/baseline_replay_5_24.xml +++ b/AnkiDroid/src/main/res/drawable/baseline_replay_5_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/baseline_wifi_24.xml b/AnkiDroid/src/main/res/drawable/baseline_wifi_24.xml index 8124347baf05..46c85aed2df0 100644 --- a/AnkiDroid/src/main/res/drawable/baseline_wifi_24.xml +++ b/AnkiDroid/src/main/res/drawable/baseline_wifi_24.xml @@ -1,19 +1,9 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + SPDX-FileComment: Style: Filled + Source: https://fonts.gstatic.com/s/i/materialicons/wifi/v12/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/footer_button_ripple.xml b/AnkiDroid/src/main/res/drawable/footer_button_ripple.xml index 2c32bc6be05a..1e9d0a758c2b 100644 --- a/AnkiDroid/src/main/res/drawable/footer_button_ripple.xml +++ b/AnkiDroid/src/main/res/drawable/footer_button_ripple.xml @@ -1,20 +1,5 @@ - - - + + diff --git a/AnkiDroid/src/main/res/drawable/help_outline.xml b/AnkiDroid/src/main/res/drawable/help_outline.xml index 8c0d6ebeedf7..2a969a3abe83 100644 --- a/AnkiDroid/src/main/res/drawable/help_outline.xml +++ b/AnkiDroid/src/main/res/drawable/help_outline.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/home_icon.xml b/AnkiDroid/src/main/res/drawable/home_icon.xml index 4c5e85410480..5528393d3931 100644 --- a/AnkiDroid/src/main/res/drawable/home_icon.xml +++ b/AnkiDroid/src/main/res/drawable/home_icon.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_accessibility_24.xml b/AnkiDroid/src/main/res/drawable/ic_accessibility_24.xml index 58b82766ba55..4552b34f01df 100644 --- a/AnkiDroid/src/main/res/drawable/ic_accessibility_24.xml +++ b/AnkiDroid/src/main/res/drawable/ic_accessibility_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_action_mic.xml b/AnkiDroid/src/main/res/drawable/ic_action_mic.xml index 591a0767d07e..cc4f4987147e 100644 --- a/AnkiDroid/src/main/res/drawable/ic_action_mic.xml +++ b/AnkiDroid/src/main/res/drawable/ic_action_mic.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_baseline_backup_24.xml b/AnkiDroid/src/main/res/drawable/ic_baseline_backup_24.xml index a86e1af24afa..c5e6f83a210d 100644 --- a/AnkiDroid/src/main/res/drawable/ic_baseline_backup_24.xml +++ b/AnkiDroid/src/main/res/drawable/ic_baseline_backup_24.xml @@ -1,19 +1,9 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + SPDX-FileComment: Style: Filled + Source: https://fonts.gstatic.com/s/i/materialicons/backup/v15/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/ic_baseline_check_box_24.xml b/AnkiDroid/src/main/res/drawable/ic_baseline_check_box_24.xml index cbb9c3e55e88..9e9b2b06a6d3 100644 --- a/AnkiDroid/src/main/res/drawable/ic_baseline_check_box_24.xml +++ b/AnkiDroid/src/main/res/drawable/ic_baseline_check_box_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml index 604d2393631f..f0164c0333b4 100644 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml +++ b/AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml index df7c61a2db53..175cb40916ca 100644 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml +++ b/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml index 0426c2940d3f..419a80c930b4 100644 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml +++ b/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml index 261e7bbbf38f..0a48a8c49228 100644 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml +++ b/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml index 2f592ac2d622..7179f5c0f8f7 100644 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml +++ b/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_chevron_right_white.xml b/AnkiDroid/src/main/res/drawable/ic_chevron_right_white.xml index a6d73497354a..4f8fd6ed2945 100644 --- a/AnkiDroid/src/main/res/drawable/ic_chevron_right_white.xml +++ b/AnkiDroid/src/main/res/drawable/ic_chevron_right_white.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_code.xml b/AnkiDroid/src/main/res/drawable/ic_code.xml index 7e39718d7041..ef8c53df6a28 100644 --- a/AnkiDroid/src/main/res/drawable/ic_code.xml +++ b/AnkiDroid/src/main/res/drawable/ic_code.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_crop.xml b/AnkiDroid/src/main/res/drawable/ic_crop.xml index 4887ceb2b6fb..20eadde33057 100644 --- a/AnkiDroid/src/main/res/drawable/ic_crop.xml +++ b/AnkiDroid/src/main/res/drawable/ic_crop.xml @@ -1,20 +1,10 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + Copyright (c) 2024 Ashish Yadav + SPDX-FileComment: Style: Filled + Source: https://fonts.gstatic.com/s/i/materialicons/crop/v12/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/ic_dialog_info.xml b/AnkiDroid/src/main/res/drawable/ic_dialog_info.xml index 226806703e20..2d53780e478f 100644 --- a/AnkiDroid/src/main/res/drawable/ic_dialog_info.xml +++ b/AnkiDroid/src/main/res/drawable/ic_dialog_info.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_done.xml b/AnkiDroid/src/main/res/drawable/ic_done.xml index 29caa791bf19..15ab25a3aaf6 100644 --- a/AnkiDroid/src/main/res/drawable/ic_done.xml +++ b/AnkiDroid/src/main/res/drawable/ic_done.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_done_white.xml b/AnkiDroid/src/main/res/drawable/ic_done_white.xml index eb7cf3a14d8b..f865b5160fe2 100644 --- a/AnkiDroid/src/main/res/drawable/ic_done_white.xml +++ b/AnkiDroid/src/main/res/drawable/ic_done_white.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_double_arrow_up.xml b/AnkiDroid/src/main/res/drawable/ic_double_arrow_up.xml index 473c86a8e240..051e8ae80622 100644 --- a/AnkiDroid/src/main/res/drawable/ic_double_arrow_up.xml +++ b/AnkiDroid/src/main/res/drawable/ic_double_arrow_up.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_drag_indicator_24.xml b/AnkiDroid/src/main/res/drawable/ic_drag_indicator_24.xml index 0221f274933e..3f7219162dd3 100644 --- a/AnkiDroid/src/main/res/drawable/ic_drag_indicator_24.xml +++ b/AnkiDroid/src/main/res/drawable/ic_drag_indicator_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_ease_easy.xml b/AnkiDroid/src/main/res/drawable/ic_ease_easy.xml index 0e910306582d..7b1e3cc72ce4 100644 --- a/AnkiDroid/src/main/res/drawable/ic_ease_easy.xml +++ b/AnkiDroid/src/main/res/drawable/ic_ease_easy.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_ease_good.xml b/AnkiDroid/src/main/res/drawable/ic_ease_good.xml index 1adc834a44fe..a011de0895eb 100644 --- a/AnkiDroid/src/main/res/drawable/ic_ease_good.xml +++ b/AnkiDroid/src/main/res/drawable/ic_ease_good.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_edit_note.xml b/AnkiDroid/src/main/res/drawable/ic_edit_note.xml index f9bc1e0f2eb7..e84cb8cc56df 100644 --- a/AnkiDroid/src/main/res/drawable/ic_edit_note.xml +++ b/AnkiDroid/src/main/res/drawable/ic_edit_note.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_fast_forward.xml b/AnkiDroid/src/main/res/drawable/ic_fast_forward.xml index 4d533a56ca63..0c695ec68a45 100644 --- a/AnkiDroid/src/main/res/drawable/ic_fast_forward.xml +++ b/AnkiDroid/src/main/res/drawable/ic_fast_forward.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_flip_horizontal.xml b/AnkiDroid/src/main/res/drawable/ic_flip_horizontal.xml index e3c07b6c1c9b..132b9805c633 100644 --- a/AnkiDroid/src/main/res/drawable/ic_flip_horizontal.xml +++ b/AnkiDroid/src/main/res/drawable/ic_flip_horizontal.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_format_bold_black_24dp.xml b/AnkiDroid/src/main/res/drawable/ic_format_bold_black_24dp.xml index 708d21264e94..8ef9d1466a20 100644 --- a/AnkiDroid/src/main/res/drawable/ic_format_bold_black_24dp.xml +++ b/AnkiDroid/src/main/res/drawable/ic_format_bold_black_24dp.xml @@ -1,3 +1,9 @@ + - ~ - ~ 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 . - --> - + diff --git a/AnkiDroid/src/main/res/drawable/ic_help_black.xml b/AnkiDroid/src/main/res/drawable/ic_help_black.xml index d136bbc85abb..eb205c3ea3f3 100644 --- a/AnkiDroid/src/main/res/drawable/ic_help_black.xml +++ b/AnkiDroid/src/main/res/drawable/ic_help_black.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_label_outline.xml b/AnkiDroid/src/main/res/drawable/ic_label_outline.xml index d12952523af6..a06cfd7169cb 100644 --- a/AnkiDroid/src/main/res/drawable/ic_label_outline.xml +++ b/AnkiDroid/src/main/res/drawable/ic_label_outline.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_language_black_24dp.xml b/AnkiDroid/src/main/res/drawable/ic_language_black_24dp.xml index cf0f79cbffe7..3287afdcd7dd 100644 --- a/AnkiDroid/src/main/res/drawable/ic_language_black_24dp.xml +++ b/AnkiDroid/src/main/res/drawable/ic_language_black_24dp.xml @@ -1,19 +1,9 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + SPDX-FileComment: Style: Filled + Source: https://fonts.gstatic.com/s/i/materialicons/translate/v16/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/ic_light_mode_24.xml b/AnkiDroid/src/main/res/drawable/ic_light_mode_24.xml index af92c0e5ee27..f1dac5537757 100644 --- a/AnkiDroid/src/main/res/drawable/ic_light_mode_24.xml +++ b/AnkiDroid/src/main/res/drawable/ic_light_mode_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_lightbulb.xml b/AnkiDroid/src/main/res/drawable/ic_lightbulb.xml index fcf60ab0e009..5c9b9523f943 100644 --- a/AnkiDroid/src/main/res/drawable/ic_lightbulb.xml +++ b/AnkiDroid/src/main/res/drawable/ic_lightbulb.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_lightbulb_stars.xml b/AnkiDroid/src/main/res/drawable/ic_lightbulb_stars.xml index 15bd63f4e7b5..d9ad4d7b97f6 100644 --- a/AnkiDroid/src/main/res/drawable/ic_lightbulb_stars.xml +++ b/AnkiDroid/src/main/res/drawable/ic_lightbulb_stars.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_list_black.xml b/AnkiDroid/src/main/res/drawable/ic_list_black.xml index 4c2fb88349ae..1a2570406fe9 100644 --- a/AnkiDroid/src/main/res/drawable/ic_list_black.xml +++ b/AnkiDroid/src/main/res/drawable/ic_list_black.xml @@ -1,3 +1,9 @@ + + ~ SPDX-License-Identifier: Apache-2.0 + ~ SPDX-FileCopyrightText: Google LLC ~ - ~ 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 . + ~ Source: https://github.com/google/material-design-icons/blob/master/symbols/web/menu/materialsymbolsoutlined/menu_24px.svg --> diff --git a/AnkiDroid/src/main/res/drawable/ic_more_vertical.xml b/AnkiDroid/src/main/res/drawable/ic_more_vertical.xml index 34b93ecdf2a2..57461985135a 100644 --- a/AnkiDroid/src/main/res/drawable/ic_more_vertical.xml +++ b/AnkiDroid/src/main/res/drawable/ic_more_vertical.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_outline_info_24.xml b/AnkiDroid/src/main/res/drawable/ic_outline_info_24.xml index ec6f4a512774..d515c748b866 100644 --- a/AnkiDroid/src/main/res/drawable/ic_outline_info_24.xml +++ b/AnkiDroid/src/main/res/drawable/ic_outline_info_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_palette.xml b/AnkiDroid/src/main/res/drawable/ic_palette.xml index c3d2c7b31b09..574a738606c9 100644 --- a/AnkiDroid/src/main/res/drawable/ic_palette.xml +++ b/AnkiDroid/src/main/res/drawable/ic_palette.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_photo_library.xml b/AnkiDroid/src/main/res/drawable/ic_photo_library.xml new file mode 100644 index 000000000000..53d1bc54ad26 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_photo_library.xml @@ -0,0 +1,15 @@ + + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_play.xml b/AnkiDroid/src/main/res/drawable/ic_play.xml index 1688fa807156..e83480ae6dde 100644 --- a/AnkiDroid/src/main/res/drawable/ic_play.xml +++ b/AnkiDroid/src/main/res/drawable/ic_play.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_play_circle_white.xml b/AnkiDroid/src/main/res/drawable/ic_play_circle_white.xml index 6fc4226d23bc..6ff8eae87b84 100644 --- a/AnkiDroid/src/main/res/drawable/ic_play_circle_white.xml +++ b/AnkiDroid/src/main/res/drawable/ic_play_circle_white.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_refresh_white.xml b/AnkiDroid/src/main/res/drawable/ic_refresh_white.xml index cc2d1e04ff61..9385a7343806 100644 --- a/AnkiDroid/src/main/res/drawable/ic_refresh_white.xml +++ b/AnkiDroid/src/main/res/drawable/ic_refresh_white.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_remove.xml b/AnkiDroid/src/main/res/drawable/ic_remove.xml index a6f35d800295..1cdf3b0985f5 100644 --- a/AnkiDroid/src/main/res/drawable/ic_remove.xml +++ b/AnkiDroid/src/main/res/drawable/ic_remove.xml @@ -1,19 +1,9 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + SPDX-FileComment: Style: Filled + Source: https://fonts.gstatic.com/s/i/materialicons/remove/v16/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/ic_remove_outline.xml b/AnkiDroid/src/main/res/drawable/ic_remove_outline.xml index 2326cd6414a0..763d939a8e39 100644 --- a/AnkiDroid/src/main/res/drawable/ic_remove_outline.xml +++ b/AnkiDroid/src/main/res/drawable/ic_remove_outline.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_replace_image.xml b/AnkiDroid/src/main/res/drawable/ic_replace_image.xml deleted file mode 100644 index c6eec75e5849..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_replace_image.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/AnkiDroid/src/main/res/drawable/ic_replay.xml b/AnkiDroid/src/main/res/drawable/ic_replay.xml index 3c6d2f25283e..e8ddc7122c47 100644 --- a/AnkiDroid/src/main/res/drawable/ic_replay.xml +++ b/AnkiDroid/src/main/res/drawable/ic_replay.xml @@ -1,3 +1,9 @@ + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/drawable/ic_rotate_right.xml b/AnkiDroid/src/main/res/drawable/ic_rotate_right.xml index 7928f7d1bee8..1ec4e689b6fb 100644 --- a/AnkiDroid/src/main/res/drawable/ic_rotate_right.xml +++ b/AnkiDroid/src/main/res/drawable/ic_rotate_right.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_round_open_in_new.xml b/AnkiDroid/src/main/res/drawable/ic_round_open_in_new.xml index 88a65a8ade78..fadfb404a998 100644 --- a/AnkiDroid/src/main/res/drawable/ic_round_open_in_new.xml +++ b/AnkiDroid/src/main/res/drawable/ic_round_open_in_new.xml @@ -1,20 +1,10 @@ - + SPDX-License-Identifier: Apache-2.0 + SPDX-FileCopyrightText: Google LLC + Copyright (c) 2024 Ashish Yadav + SPDX-FileComment: Style: Round + Source: https://fonts.gstatic.com/s/i/materialiconsround/open_in_new/v21/24px.xml +--> diff --git a/AnkiDroid/src/main/res/drawable/ic_save_white.xml b/AnkiDroid/src/main/res/drawable/ic_save_white.xml index 8ea1132080d9..4ebbcb24a937 100644 --- a/AnkiDroid/src/main/res/drawable/ic_save_white.xml +++ b/AnkiDroid/src/main/res/drawable/ic_save_white.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_search_white.xml b/AnkiDroid/src/main/res/drawable/ic_search_white.xml index d80a7d02f045..777d7d5bf1cb 100644 --- a/AnkiDroid/src/main/res/drawable/ic_search_white.xml +++ b/AnkiDroid/src/main/res/drawable/ic_search_white.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_select_all_white.xml b/AnkiDroid/src/main/res/drawable/ic_select_all_white.xml index 9f88d1887be7..35b2e992c9a0 100644 --- a/AnkiDroid/src/main/res/drawable/ic_select_all_white.xml +++ b/AnkiDroid/src/main/res/drawable/ic_select_all_white.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_skip_next.xml b/AnkiDroid/src/main/res/drawable/ic_skip_next.xml index 83bb32305d20..86b6a9f345e9 100644 --- a/AnkiDroid/src/main/res/drawable/ic_skip_next.xml +++ b/AnkiDroid/src/main/res/drawable/ic_skip_next.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_stop.xml b/AnkiDroid/src/main/res/drawable/ic_stop.xml index c5e1d147d392..abe23dcd3a80 100644 --- a/AnkiDroid/src/main/res/drawable/ic_stop.xml +++ b/AnkiDroid/src/main/res/drawable/ic_stop.xml @@ -1,3 +1,9 @@ + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/drawable/ic_support_ankidroid.xml b/AnkiDroid/src/main/res/drawable/ic_support_ankidroid.xml index b9b10ed97771..7fc05847bd3f 100644 --- a/AnkiDroid/src/main/res/drawable/ic_support_ankidroid.xml +++ b/AnkiDroid/src/main/res/drawable/ic_support_ankidroid.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_sync_problem.xml b/AnkiDroid/src/main/res/drawable/ic_sync_problem.xml index 9fd03657a70f..7757f628f08b 100644 --- a/AnkiDroid/src/main/res/drawable/ic_sync_problem.xml +++ b/AnkiDroid/src/main/res/drawable/ic_sync_problem.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_tune_white.xml b/AnkiDroid/src/main/res/drawable/ic_tune_white.xml index e8d34f1427ba..ef09029c725c 100644 --- a/AnkiDroid/src/main/res/drawable/ic_tune_white.xml +++ b/AnkiDroid/src/main/res/drawable/ic_tune_white.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_videogame.xml b/AnkiDroid/src/main/res/drawable/ic_videogame.xml index 6e78a4a0f20f..33c75c8dfa37 100644 --- a/AnkiDroid/src/main/res/drawable/ic_videogame.xml +++ b/AnkiDroid/src/main/res/drawable/ic_videogame.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/ic_warning.xml b/AnkiDroid/src/main/res/drawable/ic_warning.xml index b4e0c444830d..a41b9bb68c29 100644 --- a/AnkiDroid/src/main/res/drawable/ic_warning.xml +++ b/AnkiDroid/src/main/res/drawable/ic_warning.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/id_arrow_drop_down.xml b/AnkiDroid/src/main/res/drawable/id_arrow_drop_down.xml index cc4f0472b43d..35b05ad8218c 100644 --- a/AnkiDroid/src/main/res/drawable/id_arrow_drop_down.xml +++ b/AnkiDroid/src/main/res/drawable/id_arrow_drop_down.xml @@ -1,3 +1,9 @@ + + + diff --git a/AnkiDroid/src/main/res/drawable/intro_gradient.xml b/AnkiDroid/src/main/res/drawable/intro_gradient.xml index e8f460e0f37e..e83dab2d1294 100644 --- a/AnkiDroid/src/main/res/drawable/intro_gradient.xml +++ b/AnkiDroid/src/main/res/drawable/intro_gradient.xml @@ -1,19 +1,5 @@ - - + + diff --git a/AnkiDroid/src/main/res/drawable/round_audio_file_24.xml b/AnkiDroid/src/main/res/drawable/round_audio_file_24.xml index 56b19cb06be3..24ba4fb1c20b 100644 --- a/AnkiDroid/src/main/res/drawable/round_audio_file_24.xml +++ b/AnkiDroid/src/main/res/drawable/round_audio_file_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/drawable/round_play_arrow_24.xml b/AnkiDroid/src/main/res/drawable/round_play_arrow_24.xml index d75d7da3eb9f..d7abb8e28cd3 100644 --- a/AnkiDroid/src/main/res/drawable/round_play_arrow_24.xml +++ b/AnkiDroid/src/main/res/drawable/round_play_arrow_24.xml @@ -1,3 +1,9 @@ + diff --git a/AnkiDroid/src/main/res/layout/activity_multimedia.xml b/AnkiDroid/src/main/res/layout/activity_multimedia.xml index b8b324818dd0..f4b63e32e544 100644 --- a/AnkiDroid/src/main/res/layout/activity_multimedia.xml +++ b/AnkiDroid/src/main/res/layout/activity_multimedia.xml @@ -25,7 +25,16 @@ android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:navigationContentDescription="@string/abc_action_bar_up_description" - app:navigationIcon="?attr/homeAsUpIndicator" /> + app:navigationIcon="?attr/homeAsUpIndicator"> + + + + diff --git a/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml b/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml index 30aaf9c9a07d..9f1ffc1ba5e6 100644 --- a/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml +++ b/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml @@ -98,6 +98,8 @@ android:id="@+id/CardEditorModelText" android:layout_width="match_parent" android:layout_height="wrap_content" + android:maxLines="@integer/change_note_type_name_max_lines" + android:ellipsize="end" android:textDirection="locale" android:textStyle="bold" android:textAppearance="?attr/textAppearanceBodyLarge" /> @@ -154,21 +156,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@id/to_label" - android:layout_toStartOf="@+id/to_dropdown_arrow" android:layout_alignParentStart="true" android:background="@null" android:padding="0dp" android:minHeight="0dp" /> - - @@ -178,6 +169,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@android:color/transparent" + app:tabMode="fixed" app:tabMaxWidth="0dp" app:tabGravity="fill"/> @@ -189,4 +181,4 @@ /> - \ No newline at end of file + diff --git a/AnkiDroid/src/main/res/layout/dialog_fields.xml b/AnkiDroid/src/main/res/layout/dialog_fields.xml index 0a60821370fa..d59297f79471 100644 --- a/AnkiDroid/src/main/res/layout/dialog_fields.xml +++ b/AnkiDroid/src/main/res/layout/dialog_fields.xml @@ -10,12 +10,13 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:paddingTop="8dp" + android:paddingBottom="16dp" android:fadeScrollbars="false"> + android:paddingTop="16dp"> @@ -82,4 +83,4 @@ android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:id="@+id/tags_dialog_snackbar"/> - \ No newline at end of file + diff --git a/AnkiDroid/src/main/res/layout/dialog_templates.xml b/AnkiDroid/src/main/res/layout/dialog_templates.xml index e176347b75dc..c3685bed3c5a 100644 --- a/AnkiDroid/src/main/res/layout/dialog_templates.xml +++ b/AnkiDroid/src/main/res/layout/dialog_templates.xml @@ -10,12 +10,13 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:paddingTop="8dp" + android:paddingBottom="16dp" android:fadeScrollbars="false"> + android:paddingTop="16dp"> - + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> diff --git a/AnkiDroid/src/main/res/layout/view_tab_layout_icon_on_end.xml b/AnkiDroid/src/main/res/layout/view_tab_layout_icon_on_start.xml similarity index 98% rename from AnkiDroid/src/main/res/layout/view_tab_layout_icon_on_end.xml rename to AnkiDroid/src/main/res/layout/view_tab_layout_icon_on_start.xml index 8d9e869084c8..60f298f7831b 100644 --- a/AnkiDroid/src/main/res/layout/view_tab_layout_icon_on_end.xml +++ b/AnkiDroid/src/main/res/layout/view_tab_layout_icon_on_start.xml @@ -9,6 +9,14 @@ tools:layout_width="match_parent" > + + - - - \ No newline at end of file + tools:text="Fields"/> + diff --git a/AnkiDroid/src/main/res/values-km/16-multimedia-editor.xml b/AnkiDroid/src/main/res/values-km/16-multimedia-editor.xml deleted file mode 100644 index dc24ad5456f2..000000000000 --- a/AnkiDroid/src/main/res/values-km/16-multimedia-editor.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - Add image - Add audio clip - Add video clip - Record audio - Cloze deletion - - - Audio recording - Done - - - - - - Gallery - Camera - - - Something went wrong - Error converting clipboard image to png: %s - Attach multimedia content - Attach multimedia content to the %1$s field - - - Recording failed - Playing failed - Unable to create recorder tool bar - - Do you want to crop this image? - Crop image - Current image size is %sMB. Default image size limit is 1MB. Do you want to compress it? - "Select image failed, Please retry" - Field Contents - The image is too big to be handled by the crop editor. Please resize it and try again! - Reselect - Show audio waveform - Show field content - Delete recording - diff --git a/AnkiDroid/src/main/res/values-ky/16-multimedia-editor.xml b/AnkiDroid/src/main/res/values-ky/16-multimedia-editor.xml deleted file mode 100644 index dc24ad5456f2..000000000000 --- a/AnkiDroid/src/main/res/values-ky/16-multimedia-editor.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - Add image - Add audio clip - Add video clip - Record audio - Cloze deletion - - - Audio recording - Done - - - - - - Gallery - Camera - - - Something went wrong - Error converting clipboard image to png: %s - Attach multimedia content - Attach multimedia content to the %1$s field - - - Recording failed - Playing failed - Unable to create recorder tool bar - - Do you want to crop this image? - Crop image - Current image size is %sMB. Default image size limit is 1MB. Do you want to compress it? - "Select image failed, Please retry" - Field Contents - The image is too big to be handled by the crop editor. Please resize it and try again! - Reselect - Show audio waveform - Show field content - Delete recording - diff --git a/AnkiDroid/src/main/res/values-land/integers.xml b/AnkiDroid/src/main/res/values-land/integers.xml new file mode 100644 index 000000000000..5849eca4305c --- /dev/null +++ b/AnkiDroid/src/main/res/values-land/integers.xml @@ -0,0 +1,6 @@ + + + + + 1 + diff --git a/AnkiDroid/src/main/res/values-mn/16-multimedia-editor.xml b/AnkiDroid/src/main/res/values-mn/16-multimedia-editor.xml deleted file mode 100644 index dc24ad5456f2..000000000000 --- a/AnkiDroid/src/main/res/values-mn/16-multimedia-editor.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - Add image - Add audio clip - Add video clip - Record audio - Cloze deletion - - - Audio recording - Done - - - - - - Gallery - Camera - - - Something went wrong - Error converting clipboard image to png: %s - Attach multimedia content - Attach multimedia content to the %1$s field - - - Recording failed - Playing failed - Unable to create recorder tool bar - - Do you want to crop this image? - Crop image - Current image size is %sMB. Default image size limit is 1MB. Do you want to compress it? - "Select image failed, Please retry" - Field Contents - The image is too big to be handled by the crop editor. Please resize it and try again! - Reselect - Show audio waveform - Show field content - Delete recording - diff --git a/AnkiDroid/src/main/res/values-my/16-multimedia-editor.xml b/AnkiDroid/src/main/res/values-my/16-multimedia-editor.xml deleted file mode 100644 index dc24ad5456f2..000000000000 --- a/AnkiDroid/src/main/res/values-my/16-multimedia-editor.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - Add image - Add audio clip - Add video clip - Record audio - Cloze deletion - - - Audio recording - Done - - - - - - Gallery - Camera - - - Something went wrong - Error converting clipboard image to png: %s - Attach multimedia content - Attach multimedia content to the %1$s field - - - Recording failed - Playing failed - Unable to create recorder tool bar - - Do you want to crop this image? - Crop image - Current image size is %sMB. Default image size limit is 1MB. Do you want to compress it? - "Select image failed, Please retry" - Field Contents - The image is too big to be handled by the crop editor. Please resize it and try again! - Reselect - Show audio waveform - Show field content - Delete recording - diff --git a/AnkiDroid/src/main/res/values-sq/16-multimedia-editor.xml b/AnkiDroid/src/main/res/values-sq/16-multimedia-editor.xml deleted file mode 100644 index dc24ad5456f2..000000000000 --- a/AnkiDroid/src/main/res/values-sq/16-multimedia-editor.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - Add image - Add audio clip - Add video clip - Record audio - Cloze deletion - - - Audio recording - Done - - - - - - Gallery - Camera - - - Something went wrong - Error converting clipboard image to png: %s - Attach multimedia content - Attach multimedia content to the %1$s field - - - Recording failed - Playing failed - Unable to create recorder tool bar - - Do you want to crop this image? - Crop image - Current image size is %sMB. Default image size limit is 1MB. Do you want to compress it? - "Select image failed, Please retry" - Field Contents - The image is too big to be handled by the crop editor. Please resize it and try again! - Reselect - Show audio waveform - Show field content - Delete recording - diff --git a/AnkiDroid/src/main/res/values-sw360dp/values-preference.xml b/AnkiDroid/src/main/res/values-sw360dp/values-preference.xml index ec13efce2f48..513e95d44768 100644 --- a/AnkiDroid/src/main/res/values-sw360dp/values-preference.xml +++ b/AnkiDroid/src/main/res/values-sw360dp/values-preference.xml @@ -1,19 +1,4 @@ - - + false \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values-tgl/16-multimedia-editor.xml b/AnkiDroid/src/main/res/values-tgl/16-multimedia-editor.xml deleted file mode 100644 index dc24ad5456f2..000000000000 --- a/AnkiDroid/src/main/res/values-tgl/16-multimedia-editor.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - Add image - Add audio clip - Add video clip - Record audio - Cloze deletion - - - Audio recording - Done - - - - - - Gallery - Camera - - - Something went wrong - Error converting clipboard image to png: %s - Attach multimedia content - Attach multimedia content to the %1$s field - - - Recording failed - Playing failed - Unable to create recorder tool bar - - Do you want to crop this image? - Crop image - Current image size is %sMB. Default image size limit is 1MB. Do you want to compress it? - "Select image failed, Please retry" - Field Contents - The image is too big to be handled by the crop editor. Please resize it and try again! - Reselect - Show audio waveform - Show field content - Delete recording - diff --git a/AnkiDroid/src/main/res/values-ti/16-multimedia-editor.xml b/AnkiDroid/src/main/res/values-ti/16-multimedia-editor.xml deleted file mode 100644 index dc24ad5456f2..000000000000 --- a/AnkiDroid/src/main/res/values-ti/16-multimedia-editor.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - Add image - Add audio clip - Add video clip - Record audio - Cloze deletion - - - Audio recording - Done - - - - - - Gallery - Camera - - - Something went wrong - Error converting clipboard image to png: %s - Attach multimedia content - Attach multimedia content to the %1$s field - - - Recording failed - Playing failed - Unable to create recorder tool bar - - Do you want to crop this image? - Crop image - Current image size is %sMB. Default image size limit is 1MB. Do you want to compress it? - "Select image failed, Please retry" - Field Contents - The image is too big to be handled by the crop editor. Please resize it and try again! - Reselect - Show audio waveform - Show field content - Delete recording - diff --git a/AnkiDroid/src/main/res/values-v29/theme_dark.xml b/AnkiDroid/src/main/res/values-v29/theme_dark.xml index 707e22bdae3a..19d4e71910a8 100644 --- a/AnkiDroid/src/main/res/values-v29/theme_dark.xml +++ b/AnkiDroid/src/main/res/values-v29/theme_dark.xml @@ -1,19 +1,5 @@ - - + +