Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
d55e79e
docs: convert licenses to SPDX format
david-allison Sep 22, 2026
557ec7e
docs(libanki): document addNote
david-allison Sep 20, 2026
dbafaa8
doc: document deckTree collapse-state side effect
ujjwalkirti Sep 22, 2026
331c566
fix(fields): use Delete label in delete field dialog
anugrah-pal Sep 23, 2026
3b8cb50
test: add screenshot test for NoteTypeFieldEditor delete dialog
anugrah-pal Sep 23, 2026
99efe56
Bumped version to 2.25.0beta2
mikehardy Sep 23, 2026
5f263a6
fix(browse): allow selecting 'Default' if it has subdecks
david-allison Sep 22, 2026
c997891
fix(ACRA): exclude analytics key from BuildConfig
david-allison Sep 22, 2026
327a23e
fix(ACRA): log BuildConfig fields
david-allison Sep 22, 2026
9a54375
fix(fields): add title and Reposition button to reposition field dialog
anugrah-pal Sep 23, 2026
b9d0970
fix(reviewer): preserve scroll position on card flip
david-allison Sep 17, 2026
c07f01b
fix(reviewer): reset zoom on each new card
david-allison Sep 17, 2026
3e09dc2
fix(reviewer): await zoom before rendering
david-allison Sep 23, 2026
6ae2911
test(multimedia): cover argument restoration and recording results
david-allison Sep 21, 2026
14fbbb4
refactor(multimedia): load inputs before creating media views
david-allison Sep 21, 2026
23e8a2f
fix(multimedia): use temp file in saved state
david-allison Sep 21, 2026
43e7515
fix(multimedia): clear some caches on startup
david-allison Sep 21, 2026
b4a033d
feat(libanki): implement addNotes
david-allison Sep 20, 2026
4b8fcbe
test(reminders): cover notification permission flow
david-allison Sep 12, 2026
2cade22
refactor(test): introduce OverridePropertyRule
david-allison Sep 21, 2026
c0db915
refactor(card-browser): move clearView to a callback
david-allison Sep 13, 2026
6f20b5b
fix(card-browser): crash when scrolling after dropping a column
david-allison Sep 13, 2026
e4ad3c2
fix(card-browser): call super.clearView()
david-allison Sep 13, 2026
a9348c5
fix(preferences): context menu titles use sentence case
david-allison Sep 8, 2026
260cbae
fix: use sentence case for context menu labels
david-allison Sep 8, 2026
ad32499
fix(card-browser): keep saved selection during restore
david-allison Sep 5, 2026
ae23786
feat(collection): add Brief/Exclusive queue
david-allison Sep 17, 2026
5c70082
fix(collection): make `tryWithCol` atomic
david-allison Sep 17, 2026
95ae9b9
docs: add issue 17551 TODOs
david-allison Sep 17, 2026
aec28c8
refactor: move showSoftInput to Compat
david-allison Sep 1, 2026
dd839aa
refactor: expose View.showSoftInput
david-allison Sep 1, 2026
ba7ce63
refactor: use WindowInsetsControllerCompat to hide status bar
david-allison Sep 1, 2026
7c573be
test: pin Robolectric SDK
david-allison Sep 1, 2026
b8994ba
build(deps): bump compileSdk to 37
david-allison Sep 1, 2026
f1f1d00
fix(libanki): clear the notetype cache on undo/redo
david-allison Aug 16, 2026
ba8ebde
refactor(note-editor): extract onTemplateEditorResult()
david-allison Aug 22, 2026
7ba9166
fix(note-editor): handle 'Restore to Default'
david-allison Aug 22, 2026
574a353
fix(tests): await filtered deck options destruction
david-allison Sep 23, 2026
75d95ad
ci(emulator): retry ADB calls during setup
david-allison Sep 23, 2026
40c6635
refactor(filtered-decks): sort order position != enum value
david-allison Sep 23, 2026
36e5268
fix(filtered-decks): hide FSRS-only sort orders when FSRS is disabled
david-allison Sep 23, 2026
5f85879
fix: Properly handle spacing for spinner checkmarks
david-allison Aug 11, 2026
c5de677
fix(widget): stop using dynamic colors on startup
david-allison Sep 23, 2026
1472bd9
fix: ensure LoadingDialogFragment respects cancellable flag
Ratnesh5 Sep 22, 2026
61c4593
fix(deck-options): Easy Days accidentally set [HACK]
david-allison May 3, 2026
51f5fda
Updated strings from Crowdin
Sep 24, 2026
b10bba6
Merge remote-tracking branch 'origin/main' into ankiquest
float3 Sep 24, 2026
fda896a
docs(proguard): explain BuildConfig exception
david-allison Sep 23, 2026
1b072ca
refactor(lint): replace FixedPreferencesTitleLength with a JVM test
david-allison Aug 30, 2026
849db56
build(lint): use `tools:ignore` for 12-dont-translate.xml
david-allison Aug 30, 2026
153467d
build(lint): enable LogConditional in JVM library modules
david-allison Aug 30, 2026
dcffec1
Review ANKIQUEST_RELEASE for crash reports
float3 Sep 24, 2026
d39ca59
feat(coroutines): list caller in stack trace
david-allison Sep 22, 2026
d13b7ce
logs(collection): log `withCol` callers in exceptions
david-allison Sep 22, 2026
0c897ca
Merge remote-tracking branch 'fork/ankiquest' into merge/upstream-main
float3 Sep 24, 2026
a3760f4
Merge remote-tracking branch 'origin/main' into merge/upstream-main
float3 Sep 24, 2026
fad7ae4
Merge remote-tracking branch 'fork/main' into merge/upstream-main
float3 Sep 24, 2026
ffc6439
ci: sync upstream AnkiDroid daily
float3 Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
31 changes: 24 additions & 7 deletions .github/disable_art_metrics.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,25 @@

set -euo pipefail

# Issue 22033: ADB reported "device offline" after sys.boot_completed=1.
# The underlying cause is unknown; fixing it would likely remove the need for retries.
# Retries may help if the disconnect is transient; this is unverified in CI.
# Only use for commands safe to repeat: a failed ADB call may already have
# executed the command on the device.
retry_repeatable_adb_shell_cmd() {
local attempt output
for ((attempt = 1; attempt <= 5; attempt++)); do
if output=$(adb shell "$@"); then
printf '%s\n' "$output"
return 0
fi
sleep 1
done
return 1
}

# Both CI images use BE2A.250530.026.F3. Revisit this workaround when that changes.
image_build=$(adb shell getprop ro.build.version.incremental)
image_build=$(retry_repeatable_adb_shell_cmd getprop ro.build.version.incremental)
if [[ "$image_build" != "13894323" ]]; then
echo "::warning::⚠️ Emulator system image changed from build 13894323 to '$image_build'. Reassess the ART and DeviceConfig bugs independently; continuing with the existing workaround."
cat <<'EOF' | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
Expand All @@ -25,21 +42,21 @@ fi
# Avoid `device_config override`: it crashes SettingsToPropertiesMapper on the
# Android 16 emulator image. Disable server sync before writing the setting.
# See https://github.com/ankidroid/Anki-Android/issues/21883
adb shell su root "device_config set_sync_disabled_for_tests persistent"
adb shell su root "device_config put runtime_native metrics.write-to-statsd false"
retry_repeatable_adb_shell_cmd su root "device_config set_sync_disabled_for_tests persistent"
retry_repeatable_adb_shell_cmd su root "device_config put runtime_native metrics.write-to-statsd false"

# DeviceConfig allows Google Play images to update the native property asynchronously.
# Check the sync mode and both values before starting tests, so failed setup cannot leave metrics on.
for ((attempt = 0; attempt < 30; attempt++)); do
sync_mode=$(adb shell device_config get_sync_disabled_for_tests)
config_value=$(adb shell device_config get runtime_native metrics.write-to-statsd)
native_value=$(adb shell getprop persist.device_config.runtime_native.metrics.write-to-statsd)
sync_mode=$(retry_repeatable_adb_shell_cmd device_config get_sync_disabled_for_tests)
config_value=$(retry_repeatable_adb_shell_cmd device_config get runtime_native metrics.write-to-statsd)
native_value=$(retry_repeatable_adb_shell_cmd getprop persist.device_config.runtime_native.metrics.write-to-statsd)
printf 'ART metrics: sync disabled=%s, DeviceConfig=%s, native property=%s\n' "$sync_mode" "$config_value" "$native_value"
if [[ "$sync_mode" == "persistent" && "$config_value" == "false" && "$native_value" == "false" ]]; then
exit 0
fi
sleep 1
done

echo "Failed to disable ART statsd metrics reporting within 30 seconds" >&2
echo "Failed to disable ART statsd metrics reporting after 30 checks" >&2
exit 1
64 changes: 64 additions & 0 deletions .github/workflows/sync_upstream.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Sync upstream AnkiDroid

on:
schedule:
- cron: "23 4 * * *"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: sync-upstream
cancel-in-progress: false

jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Require the sync token
env:
UPSTREAM_SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN }}
run: |
if [ -z "$UPSTREAM_SYNC_TOKEN" ]; then
echo "::error::Set UPSTREAM_SYNC_TOKEN: a fine-grained token for this repository with Contents, Pull requests and Workflows read and write. GITHUB_TOKEN cannot push upstream's workflow changes, and pull requests it opens start no CI."
exit 1
fi

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
token: ${{ secrets.UPSTREAM_SYNC_TOKEN }}

- name: Fast-forward main to upstream
run: |
git fetch --no-tags https://github.com/ankidroid/Anki-Android.git main:refs/remotes/upstream/main
if ! git merge-base --is-ancestor HEAD refs/remotes/upstream/main; then
echo "::error::main has commits that are not upstream's. It must stay a mirror of ankidroid/Anki-Android main."
exit 1
fi
git push origin refs/remotes/upstream/main:refs/heads/main

- name: Open a pull request from main into ankiquest
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN }}
run: |
git fetch --no-tags origin ankiquest:refs/remotes/origin/ankiquest
behind=$(git rev-list --count refs/remotes/origin/ankiquest..refs/remotes/upstream/main)
if [ "$behind" -eq 0 ]; then
echo "ankiquest already contains every upstream commit"
exit 0
fi
open=$(gh pr list --repo "$GITHUB_REPOSITORY" --base ankiquest --head main --state open --json number --jq '.[].number')
if [ -n "$open" ]; then
echo "Pull request #$open is open and now includes the new commits"
exit 0
fi
gh pr create --repo "$GITHUB_REPOSITORY" --base ankiquest --head main \
--title "Merge upstream AnkiDroid" \
--body "$behind new commits from ankidroid/Anki-Android main.

Merge with a merge commit, never squash or rebase, so ankiquest keeps upstream's history.

If this conflicts, merge main into a branch from ankiquest, resolve there, and merge that branch instead. Never commit to main: it mirrors upstream."
8 changes: 5 additions & 3 deletions AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ android {
namespace = "com.ichi2.anki"

compileSdk = libs.versions.compileSdk.get().toInteger()
compileSdkMinor = libs.versions.compileSdkMinor.get().toInteger()

buildFeatures {
buildConfig = true
Expand Down Expand Up @@ -114,7 +115,7 @@ android {
//
// This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is
// needed for upgrades to be offered correctly.
versionCode=22500201
versionCode=22500202
// Quest tags have their own monotonic upgrade sequence, independent of
// the upstream beta version. Keep the eight-digit ABI-split budget.
if ((project.findProperty("customSuffix") ?: "").toString().replaceFirst(/^\.*/, "") == "quest") {
Expand All @@ -127,7 +128,7 @@ android {
}
}
// If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version...
versionName="2.25.0beta1"
versionName="2.25.0beta2"
minSdk = libs.versions.minSdk.get().toInteger()

// After #13695: change .tests_emulator.yml
Expand Down Expand Up @@ -158,7 +159,8 @@ android {
}

def apiKey = properties.getProperty("ANALYTICS_API_KEY") ?: System.getenv("ANALYTICS_API_KEY") ?: "DUMMY_API_XXX"
buildConfigField "String", "ANALYTICS_API_KEY", "\"${apiKey}\""
// Keep credentials out of BuildConfig: ACRA collects its fields through reflection.
resValue "string", "ga_api_secret", apiKey
}
signingConfigs {
release {
Expand Down
5 changes: 5 additions & 0 deletions AnkiDroid/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
# -dontobfuscate

# Used through Reflection
# Prevent R8 static class merging from pulling in unrelated holders with primitive array fields.
# ACRA crashes handling these int[] arrays, leaving BuildConfig as 'N/A'
-keep class com.ichi2.anki.BuildConfig {
public static <fields>;
}
-keep class com.ichi2.anki.**.*Fragment { *; }
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
-keep class androidx.core.app.ActivityCompat$* { *; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,21 @@

package com.ichi2.anki

import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import androidx.core.net.toUri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.testutil.GrantStoragePermission
import com.ichi2.anki.testutil.waitUntil
import com.ichi2.anki.testutil.useResumedActivity
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.instanceOf
import org.hamcrest.Matchers.not
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.fail
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

/**
Expand Down Expand Up @@ -90,40 +87,6 @@ class CardBrowserDeepLinkTest : InstrumentedTest() {
block: (CardBrowser) -> Unit,
) {
testContext.startActivity(deepLink)
val browser = awaitResumed<CardBrowser>()
try {
block(browser)
} finally {
browser.finishAndAwaitDestruction()
}
}

/** Keep the collection open until the activity can no longer access it. */
private fun Activity.finishAndAwaitDestruction() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync { finishAndRemoveTask() }
waitUntil(message = { "Timed out waiting for ${javaClass.simpleName} to be destroyed" }) {
var destroyed = false
instrumentation.runOnMainSync { destroyed = isDestroyed }
destroyed
}
}

/**
* Polls until an activity of type [T] is resumed and returns it. Fails if [T] does not resume
* within [timeout].
*/
private inline fun <reified T : Activity> awaitResumed(timeout: Duration = 10.seconds): T {
val deadline = SystemClock.uptimeMillis() + timeout.inWholeMilliseconds
val instrumentation = InstrumentationRegistry.getInstrumentation()
var activity = TestUtils.activityInstance as? T

while (activity == null && SystemClock.uptimeMillis() < deadline) {
instrumentation.waitForIdleSync()
SystemClock.sleep(50)
activity = TestUtils.activityInstance as? T
}
return activity
?: fail("Timed out waiting for ${T::class.java.simpleName}; resumed = ${TestUtils.activityInstance}")
useResumedActivity<CardBrowser>(block)
}
}
125 changes: 125 additions & 0 deletions AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckOptionsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2026 David Allison <[email protected]>
*
* 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/>.
*/

package com.ichi2.anki

import androidx.test.core.app.ActivityScenario
import com.ichi2.anki.libanki.Consts
import com.ichi2.anki.pages.DeckOptions
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.testutil.GrantStoragePermission
import com.ichi2.anki.testutil.grantPermissions
import com.ichi2.anki.workarounds.SafeWebViewLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.json.JSONTokener
import org.junit.Rule
import org.junit.Test
import kotlin.coroutines.resume
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

/** Tests for [DeckOptions] */
class DeckOptionsTest : InstrumentedTest() {
@get:Rule
val runtimePermissionRule = grantPermissions(GrantStoragePermission.storagePermission)

/**
* Regression test for [#20929](https://github.com/ankidroid/Anki-Android/issues/20929)
*/
@Test
fun easyDaysSliderHasWrapperInstalled() =
// Brittle:
// * does not reproduce the original bug
// * does not react to an upstream fix
withDeckOptions { deckOptions ->
val pointerEvents =
deckOptions.pollJavascript<String>(
"""
(function() {
const el = document.querySelector('.easy-days-settings input[type="range"]');
return el ? getComputedStyle(el).pointerEvents : null;
})()
""".trimIndent(),
)
assertThat("Easy Days slider should have pointer-events disabled", pointerEvents, equalTo("none"))
}

private fun withDeckOptions(block: suspend (DeckOptions) -> Unit) =
runBlocking {
val intent = DeckOptions.getIntent(testContext, Consts.DEFAULT_DECK_ID)
ActivityScenario.launch<SingleFragmentActivity>(intent).use { scenario ->
val fragment =
suspendCancellableCoroutine { cont ->
scenario.onActivity { activity ->
cont.resume(activity.fragment as DeckOptions)
}
}
block(fragment)
}
}
}

/**
* Returns `null` for a `null`/`undefined` result, [T] otherwise
*/
private suspend fun <T : Any> DeckOptions.pollJavascript(
js: String,
timeout: Duration = 10.seconds,
interval: Duration = 100.milliseconds,
): T = pollUntilNotNull(timeout, interval) { webViewLayout.awaitJavascript(js) }

/**
* Returns `null` for a `null`/`undefined` result, [T] otherwise
*/
@Suppress("UNCHECKED_CAST")
private suspend fun <T : Any> SafeWebViewLayout.awaitJavascript(js: String): T? {
val result =
withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
evaluateJavascript(js) { value -> cont.resume(value) }
}
}
if (result == "null") return null
return JSONTokener(result).nextValue() as? T
}

/**
* Calls [block] every [interval] until a non-null value is returned
*
* @throws kotlinx.coroutines.TimeoutCancellationException if [timeout] elapses
*/
private suspend fun <T : Any> pollUntilNotNull(
timeout: Duration,
interval: Duration,
block: suspend () -> T?,
): T =
withTimeout(timeout) {
while (true) {
val result = block()
if (result != null) return@withTimeout result
delay(interval)
}
@Suppress("UNREACHABLE_CODE")
error("unreachable")
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@
package com.ichi2.anki

import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.tests.checkWithTimeout
import com.ichi2.anki.testutil.GrantStoragePermission
import com.ichi2.anki.testutil.disableIntroductionSlide
import com.ichi2.anki.testutil.grantPermissions
import com.ichi2.anki.testutil.useResumedActivity
import com.ichi2.anki.utils.ConfigAwareSingleFragmentActivity
import org.hamcrest.Matchers.allOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
Expand All @@ -27,6 +36,10 @@ class FilteredDeckOptionsTest : InstrumentedTest() {
val deckId = col.decks.newFiltered("Filtered Testing")
deckPicker.viewModel.openDeckOptions(deckId)
}
useResumedActivity<ConfigAwareSingleFragmentActivity> {
onView(withId(R.id.deck_name_input))
.checkWithTimeout(matches(allOf(isDisplayed(), withText("Filtered Testing"))))
}
}
}
}
Loading
Loading