From d55e79ec04d246b94ca75a8b6878094c02ac307d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:32:31 +0100 Subject: [PATCH 01/54] docs: convert licenses to SPDX format Following Brayan's PR, I grepped for my name and removed 50 references AnkiDroid is standardizing licenses to SPDX. I removed my copyright lines from the files as-per docs/contributing/copyright-headers.md Issue 20954 This change was not performed using LLMs and I have verified that it is correct --- .../com/ichi2/anki/browser/BrowserColumnKey.kt | 16 +--------------- .../com/ichi2/anki/multimedia/PickedImage.kt | 1 - .../com/ichi2/anki/recorder/AudioRecorder.kt | 1 - .../ReminderTroubleshootingRepository.kt | 1 - .../anki/AbstractFlashcardViewerCommandTest.kt | 15 +-------------- .../anki/browser/BrowserColumnCollectionTest.kt | 16 +--------------- .../browser/BrowserMultiColumnAdapterTest.kt | 16 +--------------- .../ichi2/anki/browser/CardBrowserColumnTest.kt | 16 +--------------- .../anki/browser/CardBrowserFragmentTest.kt | 16 +--------------- .../com/ichi2/anki/browser/SearchHistoryTest.kt | 16 +--------------- .../java/com/ichi2/anki/browser/SortTypeTest.kt | 16 +--------------- .../anki/cardviewer/CardMediaPlayerTest.kt | 16 +--------------- .../anki/cardviewer/CardSoundConfigTest.kt | 16 +--------------- .../ichi2/anki/cardviewer/CardTemplateTest.kt | 17 ++--------------- .../anki/cardviewer/GestureProcessorTest.kt | 17 ++--------------- .../anki/cardviewer/MediaErrorHandlerTest.kt | 1 - .../OnRenderProcessGoneDelegateTest.kt | 17 ++--------------- .../com/ichi2/anki/cardviewer/TypeAnswerTest.kt | 16 +--------------- .../ichi2/anki/cardviewer/VideoPlayerTest.kt | 16 +--------------- .../ichi2/anki/cardviewer/ViewerCommandTest.kt | 16 +--------------- .../anki/deckpicker/DeckPickerViewModelTest.kt | 16 +--------------- .../anki/dialogs/CustomExceptionDataTest.kt | 16 +--------------- .../anki/dialogs/DatabaseErrorDialogTest.kt | 16 +--------------- .../EditDeckDescriptionDialogViewModelTest.kt | 16 +--------------- .../ichi2/anki/dialogs/InsertFieldDialogTest.kt | 16 +--------------- .../com/ichi2/anki/dialogs/utils/TagTestData.kt | 16 +--------------- .../InstantEditorViewModelParameterizedTest.kt | 16 +--------------- .../java/com/ichi2/anki/libanki/FieldTest.kt | 16 +--------------- .../ichi2/anki/libanki/SoundOrVideoTagTest.kt | 16 +--------------- .../com/ichi2/anki/libanki/TtsParserTest.kt | 16 +--------------- .../multimedia/MultimediaImageFragmentTest.kt | 3 +-- .../anki/previewer/PreviewerFragmentTest.kt | 16 +--------------- .../anki/reviewer/ActionButtonStatusTest.kt | 15 +-------------- .../ichi2/anki/reviewer/AnswerButtonsTest.kt | 16 +--------------- .../com/ichi2/anki/reviewer/AnswerTimerTest.kt | 16 +--------------- .../anki/reviewer/AutomaticAnswerActionTest.kt | 16 +--------------- .../anki/reviewer/AutomaticAnswerAndroidTest.kt | 16 +--------------- .../ichi2/anki/reviewer/AutomaticAnswerTest.kt | 16 +--------------- .../ichi2/anki/reviewer/BindingAndroidTest.kt | 16 +--------------- .../com/ichi2/anki/reviewer/BindingMapTest.kt | 17 ++--------------- .../java/com/ichi2/anki/reviewer/BindingTest.kt | 17 ++--------------- .../ichi2/anki/reviewer/GestureMapperTest.kt | 17 ++--------------- .../ichi2/anki/reviewer/MappableBindingTest.kt | 17 ++--------------- .../java/com/ichi2/anki/scheduling/FsrsTest.kt | 16 +--------------- .../java/com/ichi2/anki/tags/FlattenTreeTest.kt | 16 +--------------- .../com/ichi2/anki/tags/ManageTagsTestUtils.kt | 17 ++--------------- .../ichi2/anki/tags/ManageTagsViewModelTest.kt | 17 ++--------------- .../java/com/ichi2/anki/utils/OnlyOnceTest.kt | 16 +--------------- .../com/ichi2/anki/utils/ext/JSONObjectTest.kt | 16 +--------------- .../java/com/ichi2/anki/utils/ext/ParcelTest.kt | 16 +--------------- 50 files changed, 55 insertions(+), 679 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnKey.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnKey.kt index 318bbf2edf86..6816248e9585 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnKey.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnKey.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2026 David Allison - * - * 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.browser diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/PickedImage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/PickedImage.kt index 0d0796cf15fe..edc3c0e73b9d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/PickedImage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/PickedImage.kt @@ -1,4 +1,3 @@ -// SPDX-FileCopyrightText: 2026 David Allison // SPDX-FileCopyrightText: 2026 Ashish Yadav // SPDX-License-Identifier: GPL-3.0-or-later diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioRecorder.kt b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioRecorder.kt index fd43f2bedaec..378f7223277e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioRecorder.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioRecorder.kt @@ -2,7 +2,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2013 Zaur Molotnikov // SPDX-FileCopyrightText: Copyright (c) 2013 Nicolas Rauol // SPDX-FileCopyrightText: Copyright (c) 2013 Flavio Lerda -// SPDX-FileCopyrightText: Copyright (c) 2021 David Allison // SPDX-FileCopyrightText: Copyright (c) 2025 Ashish Yadav package com.ichi2.anki.recorder diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingRepository.kt index e18e76dcdcf5..81264534e417 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingRepository.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingRepository.kt @@ -1,5 +1,4 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// SPDX-FileCopyrightText: Copyright (c) 2026 David Allison package com.ichi2.anki.reviewreminders diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerCommandTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerCommandTest.kt index ed799aa6f76c..079154687f65 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerCommandTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerCommandTest.kt @@ -1,18 +1,5 @@ -/* - Copyright (c) 2020 David Allison +// SPDX-License-Identifier: GPL-3.0-or-later - 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 import androidx.test.ext.junit.runners.AndroidJUnit4 diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnCollectionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnCollectionTest.kt index 4579ee1ee6b1..9b0f1f62e4df 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnCollectionTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnCollectionTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.browser diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserMultiColumnAdapterTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserMultiColumnAdapterTest.kt index 6dcbdc370e2b..5a1a1794e237 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserMultiColumnAdapterTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserMultiColumnAdapterTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.browser diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserColumnTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserColumnTest.kt index e2592a5ed858..6d145fc376b9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserColumnTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserColumnTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.browser diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserFragmentTest.kt index c9c5e4f140a7..b65ac67fb2ba 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserFragmentTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2026 David Allison - * - * 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.browser diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/SearchHistoryTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/SearchHistoryTest.kt index cfbe78e71a6d..8d514c221ce7 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/SearchHistoryTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/SearchHistoryTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2026 David Allison - * - * 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.browser diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/SortTypeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/SortTypeTest.kt index 577131903f3b..e25f1abcadc7 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/SortTypeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/SortTypeTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2026 David Allison - * - * 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.browser diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardMediaPlayerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardMediaPlayerTest.kt index 493d1039a1fc..00180ce26911 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardMediaPlayerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardMediaPlayerTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2023 David Allison - * - * 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.cardviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardSoundConfigTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardSoundConfigTest.kt index 87207bfe5ec1..20f42271af97 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardSoundConfigTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardSoundConfigTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2023 David Allison - * - * 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.cardviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardTemplateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardTemplateTest.kt index 66dafb082146..51b5087fdb87 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardTemplateTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardTemplateTest.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2020 David Allison - * - * 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.cardviewer import android.annotation.SuppressLint diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/GestureProcessorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/GestureProcessorTest.kt index a82311dab705..485fd4780c5e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/GestureProcessorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/GestureProcessorTest.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2020 David Allison - * - * 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.cardviewer import android.content.SharedPreferences diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/MediaErrorHandlerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/MediaErrorHandlerTest.kt index 6efcfc75322b..0fc697938c96 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/MediaErrorHandlerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/MediaErrorHandlerTest.kt @@ -1,5 +1,4 @@ /* - * Copyright (c) 2020 David Allison * Copyright (c) 2021 Kael Madar * * This program is free software; you can redistribute it and/or modify it under diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegateTest.kt index 45e309893257..f4a5570f0365 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegateTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegateTest.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.cardviewer import android.content.res.Resources diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/TypeAnswerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/TypeAnswerTest.kt index 297aa6068d5f..95dcd10993c9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/TypeAnswerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/TypeAnswerTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.cardviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/VideoPlayerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/VideoPlayerTest.kt index bc42fc9c1de0..86b4111b446c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/VideoPlayerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/VideoPlayerTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.cardviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/ViewerCommandTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/ViewerCommandTest.kt index cdc3853bde21..630aacc126d8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/ViewerCommandTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/ViewerCommandTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.cardviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt index a32f9c861bde..f218e15f292e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2025 David Allison - * - * 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.deckpicker diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomExceptionDataTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomExceptionDataTest.kt index 01ddd6cd315a..d3e11aa8adf3 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomExceptionDataTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomExceptionDataTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.dialogs diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DatabaseErrorDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DatabaseErrorDialogTest.kt index b4a8f42a1636..d60fb22a8a43 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DatabaseErrorDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DatabaseErrorDialogTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2023 David Allison - * - * 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.dialogs diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt index 479e57d66f4c..6b66e3998634 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2025 David Allison - * - * 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.dialogs diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt index 2b11d6fce244..17fcae4aadff 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2026 David Allison - * - * 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.dialogs diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/TagTestData.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/TagTestData.kt index cee078426288..36eba832d06b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/TagTestData.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/TagTestData.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.dialogs.utils diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelParameterizedTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelParameterizedTest.kt index 2b88ba476f5c..840255565778 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelParameterizedTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelParameterizedTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.instanteditor diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/libanki/FieldTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/libanki/FieldTest.kt index 629dd1c19db6..92d351acf9d5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/libanki/FieldTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/libanki/FieldTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.libanki diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/libanki/SoundOrVideoTagTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/libanki/SoundOrVideoTagTest.kt index 3016d95c1a01..3bed4e692874 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/libanki/SoundOrVideoTagTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/libanki/SoundOrVideoTagTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.libanki diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/libanki/TtsParserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/libanki/TtsParserTest.kt index 17afa77815aa..12845ef83855 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/libanki/TtsParserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/libanki/TtsParserTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.libanki diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt index 79817d972665..e35b7c56e49c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt @@ -1,6 +1,5 @@ -// SPDX-FileCopyrightText: 2026 David Allison -// SPDX-FileCopyrightText: 2026 Ashish Yadav // SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav package com.ichi2.anki.multimedia diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerFragmentTest.kt index d212e9632dbd..2e83911a0091 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerFragmentTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.previewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/ActionButtonStatusTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/ActionButtonStatusTest.kt index 08cdb2a44a66..158b120a7d53 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/ActionButtonStatusTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/ActionButtonStatusTest.kt @@ -1,18 +1,5 @@ -/* - Copyright (c) 2020 David Allison +// SPDX-License-Identifier: GPL-3.0-or-later - 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.reviewer import android.content.SharedPreferences diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AnswerButtonsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AnswerButtonsTest.kt index 47348d652b28..5efc6bc29cf9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AnswerButtonsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AnswerButtonsTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AnswerTimerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AnswerTimerTest.kt index a526a3d17426..a94cb963885a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AnswerTimerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AnswerTimerTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerActionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerActionTest.kt index b34aeda091b0..e257a6857d0e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerActionTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerActionTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerAndroidTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerAndroidTest.kt index 964de8450241..a659e00ae5f4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerAndroidTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerAndroidTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerTest.kt index 8430a67696f8..1069f397dab2 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/AutomaticAnswerTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt index 97a4f7ae2f2a..c83dd35fadf8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingMapTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingMapTest.kt index e7f56be199ea..b29035b0ca5c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingMapTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingMapTest.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2020 David Allison - * - * 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.reviewer import android.view.KeyEvent diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt index 28c83f834de9..48fbce064916 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer import android.view.KeyEvent diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/GestureMapperTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/GestureMapperTest.kt index 507e323c1e88..97d9cc7e1d15 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/GestureMapperTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/GestureMapperTest.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer import android.view.ViewConfiguration diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/MappableBindingTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/MappableBindingTest.kt index 6e118328bec3..f11eba985039 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/MappableBindingTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/MappableBindingTest.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.reviewer import android.view.KeyEvent diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/FsrsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/FsrsTest.kt index 892c2f0c16a4..219298972334 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/FsrsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/FsrsTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2025 David Allison - * - * 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.scheduling diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/FlattenTreeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/FlattenTreeTest.kt index 41f69b7f7ffa..9cc8c7cdc03e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/FlattenTreeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/FlattenTreeTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2026 David Allison - * - * 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.tags diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsTestUtils.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsTestUtils.kt index f75643cda62c..635156416183 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsTestUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsTestUtils.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2026 David Allison - * - * 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.tags /** Unwraps [TagListItemState.fullTag] to a plain [String] for test assertions */ diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index f91cc8b86e87..018ab1f713cd 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2026 David Allison - * - * 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.tags import androidx.test.ext.junit.runners.AndroidJUnit4 diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/utils/OnlyOnceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/utils/OnlyOnceTest.kt index 34e79997ea8c..9b336ce378e3 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/utils/OnlyOnceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/utils/OnlyOnceTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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/test/java/com/ichi2/anki/utils/ext/JSONObjectTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/utils/ext/JSONObjectTest.kt index 04c52038deb9..f1d498c92769 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/utils/ext/JSONObjectTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/utils/ext/JSONObjectTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2024 David Allison - * - * 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.ext import androidx.test.ext.junit.runners.AndroidJUnit4 diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/utils/ext/ParcelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/utils/ext/ParcelTest.kt index 3432cca9728e..97cfc36c4946 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/utils/ext/ParcelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/utils/ext/ParcelTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright (c) 2025 David Allison - * - * 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.ext From 557ec7e6e3808f7ffbd377fc4c0d844384260210 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:16:17 +0100 Subject: [PATCH 02/54] docs(libanki): document addNote Part of 20497. add_note_inner https://github.com/ankitects/anki/blob/754ce3a25f608010c0249e074e5d7fe95bda035f/rslib/src/notes/mod.rs#L377-L392 Assisted-by: GPT-6 --- .../java/com/ichi2/anki/libanki/Collection.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt index 617b5321dbdd..757d958c5ee5 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt @@ -77,8 +77,10 @@ import com.ichi2.anki.libanki.sched.Scheduler import com.ichi2.anki.libanki.utils.LibAnkiAlias import com.ichi2.anki.libanki.utils.NotInPyLib import net.ankiweb.rsdroid.Backend +import net.ankiweb.rsdroid.BackendException.BackendFatalError import net.ankiweb.rsdroid.BackendException.BackendSearchException import net.ankiweb.rsdroid.RustCleanup +import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException import net.ankiweb.rsdroid.exceptions.BackendNotFoundException import timber.log.Timber import java.io.File @@ -660,6 +662,21 @@ class Collection( @LibAnkiAlias("new_note") fun newNote(notetype: NotetypeJson): Note = Note.fromNotetypeId(this, notetype.id) + /** + * Adds the provided note to deck: [deckId]. + * + * Updates: + * - [defaultDeckForNoteType] if 'Decide by Note Type' is set. + * - [defaultsForAdding] for [deckId], OR if 'Decide by Note Type' is set. + * + * Unchanged: + * - [Decks.selected] + * + * @return An [OpChangesWithCount], where the count is the number of generated **cards**. + * @throws BackendInvalidInputException if the note type is missing, the number of fields does + * not match the note type, or card generation needs a fallback and Default is missing or filtered. + * @throws BackendFatalError if [Note.id] is nonzero. + */ @LibAnkiAlias("add_note") fun addNote( note: Note, From dbafaa852fa0ecbf1ae1429565d4269c949d1447 Mon Sep 17 00:00:00 2001 From: ujjwalkirti Date: Tue, 22 Sep 2026 12:28:54 +0530 Subject: [PATCH 03/54] doc: document deckTree collapse-state side effect --- libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt | 3 +++ .../java/com/ichi2/anki/libanki/sched/Scheduler.kt | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt index 03f2d86a1678..eed6ae4c7c1b 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt @@ -220,6 +220,9 @@ class Decks( ) } + /** + * Returns the root node of the deck tree without counts as it uses the browser collapsed state. + */ @LibAnkiAlias("deck_tree") @Suppress("unused") fun deckTree(): DeckTreeNode = col.backend.deckTree(now = 0) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/sched/Scheduler.kt b/libanki/src/main/java/com/ichi2/anki/libanki/sched/Scheduler.kt index 1dc7e8208c86..4204ec441ff6 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/sched/Scheduler.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/sched/Scheduler.kt @@ -470,6 +470,16 @@ open class Scheduler( return tree } + /** + * Returns the root node of the tree of decks. + * + * Note: [includeCounts] also affects which collapsed/expanded state is used: + * - If true: computes card counts and uses the study/reviewer collapsed state. + * - If false: skips counts and uses the browser collapsed state. + * + * @param includeCounts a flag which helps decide whether we calculate counts of cards and which state to use among the Reviewer and Browser. + * @return The root node of the deckTree. + */ fun deckTree(includeCounts: Boolean): DeckNode = DeckNode(col.backend.deckTree(now = if (includeCounts) time.intTime() else 0), "") fun deckLimit(): String = Utils.ids2str(col.decks.active()) From 331c566e201496ee63cac7d5796869ea60a231de Mon Sep 17 00:00:00 2001 From: anugrah-pal Date: Wed, 23 Sep 2026 12:12:11 +0530 Subject: [PATCH 04/54] fix(fields): use Delete label in delete field dialog --- AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt index 0c10f0de7a3c..5e12a931500a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt @@ -299,6 +299,7 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.activity_note_type_field_edito it.setArgs( title = fieldName, message = resources.getString(R.string.field_delete_warning), + positiveButtonText = getString(R.string.dialog_positive_delete), ) it.setConfirm(confirm) showDialogFragment(it) From 3b8cb5002bc0021a658cc1599b5cdbe0bb250c3a Mon Sep 17 00:00:00 2001 From: anugrah-pal Date: Wed, 23 Sep 2026 13:55:12 +0530 Subject: [PATCH 05/54] test: add screenshot test for NoteTypeFieldEditor delete dialog --- .../ichi2/anki/AllActivitiesScreenshotTest.kt | 2 + .../anki/NoteTypeFieldEditorScreenshotTest.kt | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt index 19a6ef81d1fc..755c1dca23bb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt @@ -137,6 +137,8 @@ class AllActivitiesScreenshotTest : ScreenshotTest() { Info::class.java, // NoteEditorScreenshotTest NoteEditorActivity::class.java, + // NoteTypeFieldEditorScreenshotTest + NoteTypeFieldEditor::class.java, ) return ActivityList .allActivitiesAndIntents() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt new file mode 100644 index 000000000000..d5730e38b79d --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.NoteTypeFieldEditorContextMenuAction +import org.junit.Test + +/** + * Screenshot tests for [NoteTypeFieldEditor] + * + * `./gradlew :AnkiDroid:recordRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.NoteTypeFieldEditorScreenshotTest"` + */ +class NoteTypeFieldEditorScreenshotTest : ScreenshotTest() { + @Test + fun base() = + withNoteTypeFieldEditor { + captureScreen("base") + } + + @Test + fun deleteFieldDialog() = + withNoteTypeFieldEditor { activity -> + activity.handleAction(NoteTypeFieldEditorContextMenuAction.Delete) + advanceRobolectricLooper() + captureScreen("delete_field_dialog") + } + + private fun withNoteTypeFieldEditor(block: (NoteTypeFieldEditor) -> Unit) { + val notetype = getCurrentDatabaseNoteTypeCopy("Basic") + val intent = + Intent(targetContext, NoteTypeFieldEditor::class.java).apply { + putExtra(NoteTypeFieldEditor.EXTRA_NOTETYPE_NAME, notetype.name) + putExtra(NoteTypeFieldEditor.EXTRA_NOTETYPE_ID, notetype.id) + } + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + advanceRobolectricLooper() + block(activity) + } + } + } +} From 99efe56916dfd3ae44f974d8ccf98f4be195b9d0 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Wed, 23 Sep 2026 11:31:20 +0000 Subject: [PATCH 06/54] Bumped version to 2.25.0beta2 --- AnkiDroid/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 69f980529bf6..298bacb67d40 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -113,9 +113,9 @@ 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 // 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 From 5f263a655c7f77aea43143c85a6abc16952f69e8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:46:43 +0100 Subject: [PATCH 07/54] fix(browse): allow selecting 'Default' if it has subdecks Fixes 21983 Assisted-by: GPT-6 --- .../ichi2/anki/dialogs/DeckSelectionDialog.kt | 11 +++--- .../dialogs/DeckSelectionDefaultDeckTest.kt | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DeckSelectionDefaultDeckTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt index d3d00a2bd287..f8f976137c5c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt @@ -53,6 +53,7 @@ import com.ichi2.anki.deckpicker.DeckFilters import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.ARG_SELECTED_DECK import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.REQUEST_SELECT_DECK import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.model.SelectableDeck @@ -508,7 +509,7 @@ fun Fragment.registerDeckSelectedHandler( * @param allowMultipleSelection if selection of multiple decks is permitted * @param allowAll shows 'All decks' option if true * @param allowFiltered shows filtered decks if true - * @param skipEmptyDefault if true, hides the 'Default' deck if it's empty + * @param skipEmptyDefault if true, hides the 'Default' deck if it has no cards or subdecks */ fun Fragment.startDeckSelection( title: String? = null, @@ -527,7 +528,8 @@ fun Fragment.startDeckSelection( withCol { decks.allNamesAndIds( includeFiltered = allowFiltered, - skipEmptyDefault = skipEmptyDefault, + // Parent decks must remain selectable so users can include their subdecks. + skipEmptyDefault = skipEmptyDefault && decks.children(DEFAULT_DECK_ID).isEmpty(), ) } val decks: MutableList = @@ -565,7 +567,7 @@ fun Fragment.startDeckSelection( * @param allowMultipleSelection if selection of multiple decks is permitted * @param allowAll shows 'All decks' option if true * @param allowFiltered shows filtered decks if true - * @param skipEmptyDefault if true, hides the 'Default' if it's empty + * @param skipEmptyDefault if true, hides the 'Default' deck if it has no cards or subdecks */ fun AnkiActivity.startDeckSelection( title: String? = null, @@ -583,7 +585,8 @@ fun AnkiActivity.startDeckSelection( withCol { decks.allNamesAndIds( includeFiltered = allowFiltered, - skipEmptyDefault = skipEmptyDefault, + // Parent decks must remain selectable so users can include their subdecks. + skipEmptyDefault = skipEmptyDefault && decks.children(DEFAULT_DECK_ID).isEmpty(), ) } val decks: MutableList = diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DeckSelectionDefaultDeckTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DeckSelectionDefaultDeckTest.kt new file mode 100644 index 000000000000..c649e6ad3c0e --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DeckSelectionDefaultDeckTest.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.dialogs + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.ichi2.anki.AnkiActivity +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID +import com.ichi2.anki.model.SelectableDeck +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class DeckSelectionDefaultDeckTest : RobolectricTest() { + @Test + fun `renamed default with cards only in a subdeck can be selected`() { + col.decks.rename(col.decks.getDefault(), "Words") + addNoteToDeck(addDeck("Words::Vocabulary")) + + val activity = startRegularActivity() + var selectedDeck: SelectableDeck? = null + activity.registerDeckSelectedHandler { selectedDeck = it } + activity.startDeckSelection(skipEmptyDefault = true) + advanceRobolectricLooper() + + onView(withText("Words")).inRoot(isDialog()).perform(click()) + assertEquals(SelectableDeck.Deck(DEFAULT_DECK_ID, "Words"), selectedDeck) + } +} From c997891fdd0fe09d8b88914ac25217441a6364dc Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:38:11 +0100 Subject: [PATCH 08/54] fix(ACRA): exclude analytics key from BuildConfig ACRA's BuildConfig collector was broken, but would have included it Assisted-by: GPT-6 --- AnkiDroid/build.gradle | 3 +- .../anki/analytics/AnkiDroidUsageAnalytics.kt | 2 +- .../com/ichi2/anki/AcraBuildConfigTest.kt | 51 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 298bacb67d40..1f10f27c9491 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -146,7 +146,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 { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/analytics/AnkiDroidUsageAnalytics.kt b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/AnkiDroidUsageAnalytics.kt index 4d34f3d2e55a..7a82e0098c55 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/analytics/AnkiDroidUsageAnalytics.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/AnkiDroidUsageAnalytics.kt @@ -131,7 +131,7 @@ internal object AnkiDroidUsageAnalytics : UsageAnalytics { analytics = GoogleAnalytics.builder { measurementId = analyticsContext.getString(R.string.ga_trackingId) - apiSecret = BuildConfig.ANALYTICS_API_KEY + apiSecret = analyticsContext.getString(R.string.ga_api_secret) appName = analyticsContext.getString(R.string.app_name) appVersion = BuildConfig.VERSION_NAME enabled = optIn diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt new file mode 100644 index 000000000000..893174b28dc0 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.app.Application +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.acra.ReportField +import org.acra.builder.ReportBuilder +import org.acra.collector.ReflectionCollector +import org.acra.config.CoreConfigurationBuilder +import org.acra.data.CrashReportData +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.containsInAnyOrder +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +class AcraBuildConfigTest { + @Test + fun `only reviewed build metadata is collected`() { + val config = CoreConfigurationBuilder().withBuildConfigClass(BuildConfig::class.java).build() + val report = CrashReportData() + + ReflectionCollector().collect(ReportField.BUILD_CONFIG, getApplicationContext(), config, ReportBuilder(), report) + + val buildConfig = report[ReportField.BUILD_CONFIG.name] as JSONObject + assertThat( + "Review new BuildConfig fields for credentials and personal data before adding them here", + buildConfig.keys().asSequence().toList(), + containsInAnyOrder( + "ACRA_URL", + "APPLICATION_ID", + "BACKEND_VERSION", + "BUILD_TIME", + "BUILD_TYPE", + "CI", + "DEBUG", + "ENABLE_LEAK_CANARY", + "FLAVOR", + "GIT_COMMIT_HASH", + "SHOW_DONATE_LINKS", + "VERSION_CODE", + "VERSION_NAME", + ), + ) + } +} From 327a23e17552cdd107a8b0eeba0d5127d19643ff Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:39:12 +0100 Subject: [PATCH 09/54] fix(ACRA): log BuildConfig fields Previously this failed as a ClassCastException and "BUILD_CONFIG": "N/A" ACRA could not cope with R8 placing unrelated code in BuildConfig (primitive int arrays from the audio parser) Assisted-by: GPT-6 --- AnkiDroid/proguard-rules.pro | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AnkiDroid/proguard-rules.pro b/AnkiDroid/proguard-rules.pro index c814ee1ce624..6d6f31bb856f 100644 --- a/AnkiDroid/proguard-rules.pro +++ b/AnkiDroid/proguard-rules.pro @@ -24,6 +24,10 @@ # -dontobfuscate # Used through Reflection +# ACRA reads BuildConfig fields through reflection. +-keep class com.ichi2.anki.BuildConfig { + public static ; +} -keep class com.ichi2.anki.**.*Fragment { *; } -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } -keep class androidx.core.app.ActivityCompat$* { *; } From 9a5437561ba9d3465d38414635f2a24a4e71dca2 Mon Sep 17 00:00:00 2001 From: anugrah-pal Date: Wed, 23 Sep 2026 20:20:00 +0530 Subject: [PATCH 10/54] fix(fields): add title and Reposition button to reposition field dialog --- .../src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt | 3 ++- .../com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt index 5e12a931500a..6cf6309ab92d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt @@ -400,7 +400,8 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.activity_note_type_field_edito AlertDialog .Builder(this) .show { - positiveButton(R.string.dialog_ok) { + title(R.string.model_field_editor_reposition_menu) + positiveButton(text = TR.actionsReposition()) { val input = (it as AlertDialog).getInputField() result(input.text.toString().toInt()) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt index d5730e38b79d..a35cbf8da304 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt @@ -27,6 +27,14 @@ class NoteTypeFieldEditorScreenshotTest : ScreenshotTest() { captureScreen("delete_field_dialog") } + @Test + fun repositionFieldDialog() = + withNoteTypeFieldEditor { activity -> + activity.handleAction(NoteTypeFieldEditorContextMenuAction.Reposition) + advanceRobolectricLooper() + captureScreen("reposition_field_dialog") + } + private fun withNoteTypeFieldEditor(block: (NoteTypeFieldEditor) -> Unit) { val notetype = getCurrentDatabaseNoteTypeCopy("Basic") val intent = From b9d0970517c2c2c2d7bb2e971e9cdb5809dfa9f8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:30:37 +0100 Subject: [PATCH 11/54] fix(reviewer): preserve scroll position on card flip Prefer `WebView.zoomBy()` over `loadWithOverviewMode` as overview mode reset the scroll position. Part of 5182 Assisted-by: GPT-6 --- .../ichi2/anki/ReviewerFragmentScrollTest.kt | 249 ++++++++++++++++++ .../ui/windows/reviewer/ReviewerFragment.kt | 28 +- .../anki/workarounds/SafeWebViewLayout.kt | 3 + 3 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt new file mode 100644 index 000000000000..8ac544e41c79 --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +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.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.previewer.CardViewerActivity +import com.ichi2.anki.tests.InstrumentedTest +import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission +import com.ichi2.anki.testutil.ensureWebViewIsSupported +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.workarounds.SafeWebViewLayout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +/** Tests scroll behaviour of [ReviewerFragment]. */ +@RunWith(AndroidJUnit4::class) +class ReviewerFragmentScrollTest : InstrumentedTest() { + @get:Rule + val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission) + + private var testDeckId: DeckId = 0 + private val newReviewerOptionsKey get() = testContext.getString(R.string.new_reviewer_options_key) + private var originalNewReviewerOptions: Boolean? = null + + @Before + fun setUp() { + val preferences = testContext.sharedPrefs() + originalNewReviewerOptions = preferences.all[newReviewerOptionsKey] as Boolean? + preferences.edit { putBoolean(newReviewerOptionsKey, true) } + testDeckId = col.decks.addNormalDeckWithName("ReviewerFragmentScrollTest-${UUID.randomUUID()}").id + col.decks.select(testDeckId) + } + + @After + fun tearDown() { + testContext.sharedPrefs().edit { + val original = originalNewReviewerOptions + if (original == null) remove(newReviewerOptionsKey) else putBoolean(newReviewerOptionsKey, original) + } + col.decks.remove(listOf(testDeckId)) + } + + /** Issue 5182: revealing a cloze without an answer anchor must preserve the viewport. */ + @Test + fun showingAnswerPreservesScrollPosition() { + addTallClozeCard("answer") + + withReviewer { + waitForCloze("[...]") + scrollDown() + clickShowAnswer() + waitForCloze("answer") + awaitAnimationFrames() + + assertEquals(SCROLL_POSITION.toDouble(), pageScrollY, "scroll position after revealing the cloze") + } + } + + @Test + fun showingAnswerResetsZoomAndPreservesScrollPosition() { + addTallClozeCard("answer") + + withReviewer { + waitForCloze("[...]") + val originalScale = pageScale + zoomIn() + scrollDown() + clickShowAnswer() + waitForCloze("answer") + waitForScale(originalScale) + awaitAnimationFrames() + + assertEquals(SCROLL_POSITION.toDouble(), pageScrollY, "scroll position after resetting zoom on card flip") + } + } + + @Test + fun answerAnchorTakesPrecedenceAfterResettingZoom() { + addNoteUsingBasicNoteType("${SPACER}question", "answer$SPACER").firstCard(col).update { did = testDeckId } + + withReviewer { + waitUntil(timeout = 30.seconds, message = { "Question did not load" }) { + evaluateScript("document.body.textContent.includes('question')") == "true" + } + val originalScale = pageScale + zoomIn() + scrollDown() + clickShowAnswer() + waitForScale(originalScale) + awaitAnimationFrames() + + waitUntil(message = { "Answer anchor was not scrolled into view after resetting zoom" }) { + evaluateScript("Math.abs(document.getElementById('answer')?.getBoundingClientRect().top) <= 1") == "true" + } + } + } + + @Test + fun answerAnchorTakesPrecedenceOverScrollPosition() { + addNoteUsingBasicNoteType("${SPACER}question", "answer$SPACER").firstCard(col).update { did = testDeckId } + + withReviewer { + waitUntil(timeout = 30.seconds, message = { "Question did not load" }) { + evaluateScript("document.body.textContent.includes('question')") == "true" + } + scrollDown() + clickShowAnswer() + + waitUntil(message = { "Answer anchor was not scrolled into view" }) { + evaluateScript("Math.abs(document.getElementById('answer')?.getBoundingClientRect().top) <= 1") == "true" + } + } + } + + @Test + fun nextQuestionStartsAtTop() = checkNextQuestionStartsAtTop(zoom = false) + + @Test + fun nextQuestionResetsZoomAndStartsAtTop() = checkNextQuestionStartsAtTop(zoom = true) + + private fun checkNextQuestionStartsAtTop(zoom: Boolean) { + addTallClozeCard("first") + addTallClozeCard("second") + + withReviewer { + waitForCloze("[...]") + clickShowAnswer() + waitUntil(message = { "Answer did not load" }) { + evaluateScript("['first', 'second'].includes(document.querySelector('.cloze')?.textContent)") == "true" + } + val originalScale = pageScale + if (zoom) zoomIn() + scrollDown() + onView(withId(R.id.good_button)).perform(click()) + waitForCloze("[...]") + waitForScale(originalScale) + awaitAnimationFrames() + + assertEquals(0.0, pageScrollY, "scroll position on the next question") + } + } + + private fun addTallClozeCard(answer: String) { + addClozeNote("$SPACER{{c1::$answer}}$SPACER").firstCard(col).update { did = testDeckId } + } + + private fun withReviewer(block: SafeWebViewLayout.() -> Unit) { + ActivityScenario.launch(ReviewerFragment.getIntent(testContext)).use { scenario -> + lateinit var webViewLayout: SafeWebViewLayout + scenario.onActivity { activity -> + val fragment = activity.fragment as ReviewerFragment + webViewLayout = fragment.binding.webViewLayout + } + webViewLayout.block() + } + } + + private fun clickShowAnswer() { + onView(withId(R.id.show_answer_button)).perform(click()) + } + + private fun SafeWebViewLayout.waitForCloze(text: String) { + waitUntil(timeout = 30.seconds, message = { "Cloze did not display '$text'" }) { + evaluateScript("document.querySelector('.cloze')?.textContent") == "\"$text\"" + } + } + + private fun SafeWebViewLayout.scrollDown() { + evaluateScript("window.scrollTo(0, $SCROLL_POSITION)") + waitUntil(message = { "Card did not scroll to $SCROLL_POSITION" }) { + pageScrollY == SCROLL_POSITION.toDouble() + } + } + + private val SafeWebViewLayout.pageScrollY: Double + get() = evaluateScript("window.scrollY").toDouble() + + private val SafeWebViewLayout.pageScale: Double + get() = evaluateScript("window.visualViewport.scale").toDouble() + + private fun SafeWebViewLayout.zoomIn() { + val originalScale = pageScale + InstrumentationRegistry.getInstrumentation().runOnMainSync { + zoomBy(2f) + } + waitForScale(originalScale * 2) + } + + private fun SafeWebViewLayout.waitForScale(expectedScale: Double) { + waitUntil(message = { "Expected page scale $expectedScale, got $pageScale" }) { + abs(pageScale - expectedScale) < 0.01 + } + } + + /** Allow queued layout and scroll changes to finish before asserting the viewport position. */ + private fun SafeWebViewLayout.awaitAnimationFrames() { + evaluateScript( + """ + window.scrollTestReady = false; + requestAnimationFrame(() => requestAnimationFrame(() => { window.scrollTestReady = true; })); + """.trimIndent(), + ) + waitUntil(message = { "WebView did not render" }) { + evaluateScript("window.scrollTestReady") == "true" + } + } + + /** Evaluates [script] on the UI thread and returns its JSON-encoded result. */ + private fun SafeWebViewLayout.evaluateScript(script: String): String { + val result = CompletableDeferred() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + evaluateJavascript(script) { result.complete(it) } + } + return runBlocking { withTimeout(10.seconds) { result.await() } } + } + + companion object { + private const val SCROLL_POSITION = 800 + + /** Filler which makes a card taller than the viewport, so that it can be scrolled. */ + private const val SPACER = "
Tall card content
" + + @JvmStatic + @BeforeClass + fun checkWebView() = ensureWebViewIsSupported() + } +} 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 dedd16bfac9f..dabdecb3c382 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 @@ -17,6 +17,7 @@ import android.webkit.WebView import android.widget.EditText import android.widget.FrameLayout import android.widget.LinearLayout +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.ActionMenuView import androidx.constraintlayout.widget.ConstraintLayout @@ -90,6 +91,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber +import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt import kotlin.reflect.jvm.jvmName @@ -102,10 +104,13 @@ class ReviewerFragment : TagsDialogListener, ShakeDetector.Listener { override val viewModel: ReviewerViewModel by viewModels() - private val binding by viewBinding(FragmentReviewerBinding::bind) + + @VisibleForTesting + internal val binding by viewBinding(FragmentReviewerBinding::bind) override val webViewLayout: SafeWebViewLayout get() = binding.webViewLayout private lateinit var bindingMap: BindingMap + private lateinit var reviewerWebViewClient: ReviewerWebViewClient private var shakeDetector: AnkiShakeDetector? = null private val whiteboardFragment get() = childFragmentManager.findFragmentByTag(WhiteboardFragment::class.jvmName) as? WhiteboardFragment private val isBigScreen: Boolean get() = resources.configuration.smallestScreenWidthDp >= 720 @@ -191,7 +196,7 @@ class ReviewerFragment : } viewModel.showingAnswer.collectIn(lifecycleScope) { - resetZoom() + reviewerWebViewClient.resetZoom() // focus on the whole layout so motion controllers can be captured // without navigating the other View elements binding.rootLayout.requestFocus() @@ -333,11 +338,6 @@ class ReviewerFragment : } } - private fun resetZoom() { - webViewLayout.settings.loadWithOverviewMode = false - webViewLayout.settings.loadWithOverviewMode = true - } - @NeedsTest("Whiteboard takes priority on key events") override fun dispatchKeyEvent(event: KeyEvent): Boolean { if ( @@ -663,14 +663,17 @@ class ReviewerFragment : stateFilter: CardStateFilter, ) = viewModel.onEditedTags(selectedTags) - override fun onCreateWebViewClient(savedInstanceState: Bundle?): CardViewerWebViewClient = ReviewerWebViewClient(savedInstanceState) + override fun onCreateWebViewClient(savedInstanceState: Bundle?): CardViewerWebViewClient = + ReviewerWebViewClient(savedInstanceState).also { reviewerWebViewClient = it } override fun onCreateWebChromeClient(): CardViewerWebChromeClient = ReviewerWebChromeClient() private inner class ReviewerWebViewClient( savedInstanceState: Bundle?, ) : CardViewerWebViewClient(savedInstanceState) { - private var scale: Float = if (!isRobolectric) webViewLayout.scale else 1F + // Capture the fresh WebView's scale before any pinch zoom; this includes screen density. + private val initialScale: Float = if (!isRobolectric) webViewLayout.scale else 1F + private var scale: Float = initialScale private var isScrolling: Boolean = false private var isScrollingJob: Job? = null private val gestureParser by lazy { @@ -681,6 +684,13 @@ class ReviewerFragment : } private var hasShownUnsupportedFeatureWarning = false + /** Resets pinch zoom, leaving the viewport untouched when already at the initial scale. */ + fun resetZoom() { + val factor = initialScale / scale + if (abs(factor - 1f) < 0.001f) return + webViewLayout.zoomBy(factor.coerceIn(0.01f, 100f)) + } + init { webViewLayout.setOnScrollChangeListener { _, _, _, _, _ -> isScrolling = true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/SafeWebViewLayout.kt b/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/SafeWebViewLayout.kt index 9a6341b57834..60b839172e5f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/SafeWebViewLayout.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/workarounds/SafeWebViewLayout.kt @@ -63,6 +63,9 @@ open class SafeWebViewLayout : @Suppress("DEPRECATION") val scale get() = webView.scale + @MainThread + fun zoomBy(zoomFactor: Float) = webView.zoomBy(zoomFactor) + @MainThread fun setWebViewClient(webViewClient: SafeWebViewClient) { if (warnIfNotActive("setWebViewClient")) return From c07f01bf41fb4a704bb0581e433a32d54516a628 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:16:15 +0100 Subject: [PATCH 12/54] fix(reviewer): reset zoom on each new card Burying a card did not perform a reset, as `showingAnswer` did not change. Assisted-by: Claude Opus 5 Assisted-by: GPT-6 - zoomBy reset --- .../ichi2/anki/ReviewerFragmentScrollTest.kt | 60 +++++++++++++++++-- .../ui/windows/reviewer/ReviewerFragment.kt | 10 +++- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt index 8ac544e41c79..7e03d5e8b4e8 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt @@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.preferences.reviewer.ViewerAction import com.ichi2.anki.previewer.CardViewerActivity import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission @@ -162,21 +163,70 @@ class ReviewerFragmentScrollTest : InstrumentedTest() { } } + /** Moving to the next card resets the viewport even when the answer was never shown. */ + @Test + fun buryingQuestionStartsNextQuestionAtTop() = checkBuryingQuestionStartsNextQuestionAtTop(zoom = false) + + @Test + fun buryingQuestionResetsZoomAndStartsNextQuestionAtTop() = checkBuryingQuestionStartsNextQuestionAtTop(zoom = true) + + private fun checkBuryingQuestionStartsNextQuestionAtTop(zoom: Boolean) { + addLabelledClozeCard("first") + addLabelledClozeCard("second") + + withReviewer { fragment -> + val buriedLabel = waitForAnyLabel() + val originalScale = pageScale + if (zoom) zoomIn() + scrollDown() + fragment.executeAction(ViewerAction.BURY_CARD) + waitUntil(message = { "Next question did not load" }) { + currentLabel.let { it.isNotEmpty() && it != buriedLabel } + } + waitForScale(originalScale) + awaitAnimationFrames() + + assertEquals(0.0, pageScrollY, "scroll position after burying a card") + } + } + private fun addTallClozeCard(answer: String) { addClozeNote("$SPACER{{c1::$answer}}$SPACER").firstCard(col).update { did = testDeckId } } - private fun withReviewer(block: SafeWebViewLayout.() -> Unit) { + /** Adds a tall cloze card whose question side is identifiable by [label]. */ + private fun addLabelledClozeCard(label: String) { + addClozeNote("
$label
$SPACER{{c1::answer}}$SPACER") + .firstCard(col) + .update { did = testDeckId } + } + + private fun withReviewer(block: SafeWebViewLayout.(ReviewerFragment) -> Unit) { ActivityScenario.launch(ReviewerFragment.getIntent(testContext)).use { scenario -> - lateinit var webViewLayout: SafeWebViewLayout + lateinit var fragment: ReviewerFragment scenario.onActivity { activity -> - val fragment = activity.fragment as ReviewerFragment - webViewLayout = fragment.binding.webViewLayout + fragment = activity.fragment as ReviewerFragment } - webViewLayout.block() + fragment.binding.webViewLayout.block(fragment) + } + } + + private fun ReviewerFragment.executeAction(action: ViewerAction) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + viewModel.executeAction(action) } } + /** The label of the currently displayed card, or `""` if no card is displayed. */ + private val SafeWebViewLayout.currentLabel: String + get() = evaluateScript("document.getElementById('label')?.textContent ?? ''").removeSurrounding("\"") + + /** Waits for a labelled card to display, returning its label. */ + private fun SafeWebViewLayout.waitForAnyLabel(): String { + waitUntil(timeout = 30.seconds, message = { "Question did not load" }) { currentLabel.isNotEmpty() } + return currentLabel + } + private fun clickShowAnswer() { onView(withId(R.id.show_answer_button)).perform(click()) } 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 dabdecb3c382..6563a2f9fe63 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 @@ -195,13 +195,19 @@ class ReviewerFragment : } } - viewModel.showingAnswer.collectIn(lifecycleScope) { - reviewerWebViewClient.resetZoom() + viewModel.showingAnswer.collectIn(lifecycleScope) { showingAnswer -> + // reset zoom if flipped + if (showingAnswer) reviewerWebViewClient.resetZoom() // focus on the whole layout so motion controllers can be captured // without navigating the other View elements binding.rootLayout.requestFocus() } + // reset zoom if the card changes (incl. bury/suspend) + viewModel.onCardUpdatedFlow.collectIn(lifecycleScope) { + reviewerWebViewClient.resetZoom() + } + viewModel.navigateFlow.collectIn(lifecycleScope) { destination -> if (destination is DeckOptionsDestination && destination.options.size > 1) { requireContext().showDeckOptionsSelectionDialog(destination.options) { selectedOption -> From 3e09dc2328f1871121be078695a86e84cd8e8909 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:51:54 +0100 Subject: [PATCH 13/54] fix(reviewer): await zoom before rendering When flipping a card, we could scroll to `id="answer"` before resetting the zoom finished. After the reset, the viewport was in an incorrect position. Part of 5182 Assisted-by: GPT-6 --- .../ichi2/anki/ReviewerFragmentScrollTest.kt | 40 +++++++++++++++++++ .../main/assets/scripts/ankidroid-reviewer.js | 18 +++++++++ .../ui/windows/reviewer/ReviewerFragment.kt | 4 ++ 3 files changed, 62 insertions(+) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt index 7e03d5e8b4e8..5a1cb93361d0 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt @@ -118,6 +118,46 @@ class ReviewerFragmentScrollTest : InstrumentedTest() { } } + /** Force answer rendering to race ahead of the native zoom reset. */ + @Test + fun answerRenderingWaitsForZoomReset() { + addTallClozeCard("answer") + + withReviewer { + waitForCloze("[...]") + val originalScale = pageScale + zoomIn() + scrollDown() + + evaluateScript( + """ + window.zoomResetQueued = false; + _queueAction(() => { window.zoomResetQueued = true; }); + ankidroid.waitForZoomReset($originalScale); + _showAnswer("$SPACER
answer$SPACER", 'card'); + """.trimIndent(), + ) + waitUntil(message = { "Previous card updates did not finish" }) { + evaluateScript("window.zoomResetQueued") == "true" + } + // The answer must still be pending after the queued JavaScript microtasks: + // we deliberately have not requested the native zoom reset yet. + assertEquals( + "false", + evaluateScript("document.getElementById('answer') !== null"), + "answer rendered before the zoom reset", + ) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + zoomBy(0.5f) + } + waitForScale(originalScale) + waitUntil(message = { "Answer anchor was not scrolled into view after the delayed zoom reset" }) { + evaluateScript("Math.abs(document.getElementById('answer')?.getBoundingClientRect().top) <= 1") == "true" + } + } + } + @Test fun answerAnchorTakesPrecedenceOverScrollPosition() { addNoteUsingBasicNoteType("${SPACER}question", "answer$SPACER").firstCard(col).update { did = testDeckId } diff --git a/AnkiDroid/src/main/assets/scripts/ankidroid-reviewer.js b/AnkiDroid/src/main/assets/scripts/ankidroid-reviewer.js index 96994bea7498..eb63d63752b0 100644 --- a/AnkiDroid/src/main/assets/scripts/ankidroid-reviewer.js +++ b/AnkiDroid/src/main/assets/scripts/ankidroid-reviewer.js @@ -1,6 +1,24 @@ "use strict"; globalThis.ankidroid = globalThis.ankidroid || {}; +/** Hold card rendering until the renderer has applied the native zoom reset. */ +globalThis.ankidroid.waitForZoomReset = function (scale) { + _queueAction( + () => + new Promise(resolve => { + const viewport = window.visualViewport; + function onResize() { + if (Math.abs(viewport.scale - scale) >= 0.001) return; + viewport.removeEventListener("resize", onResize); + resolve(); + } + viewport.addEventListener("resize", onResize); + // The reset may have completed before this queued action runs. + onResize(); + }), + ); +}; + globalThis.ankidroid.userAction = function (number) { try { let userJs = globalThis[`userJs${number}`]; 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 6563a2f9fe63..fe88b4f60cd5 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 @@ -694,6 +694,10 @@ class ReviewerFragment : fun resetZoom() { val factor = initialScale / scale if (abs(factor - 1f) < 0.001f) return + // zoomBy is asynchronous. Keep _showQuestion/_showAnswer queued until the renderer + // has reset its visual viewport, otherwise zoom completion can undo answer anchoring. + val pageScale = initialScale / resources.displayMetrics.density + webViewLayout.evaluateJavascript("ankidroid.waitForZoomReset($pageScale);") webViewLayout.zoomBy(factor.coerceIn(0.01f, 100f)) } From 6ae2911faf73a16462c02f3def83064258424f07 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:34:45 +0100 Subject: [PATCH 14/54] test(multimedia): cover argument restoration and recording results * parcel round trips * activity recreation * simulated process death Part of 21953 Assisted-by: GPT-6 --- .../anki/multimedia/MultimediaActivityTest.kt | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaActivityTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaActivityTest.kt new file mode 100644 index 000000000000..12dbaade033c --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaActivityTest.kt @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.multimedia + +import android.Manifest.permission.RECORD_AUDIO +import android.content.Intent +import android.os.Bundle +import android.os.Parcel +import android.widget.TextView +import androidx.core.view.descendants +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.multimediacard.fields.AudioRecordingField +import com.ichi2.anki.multimediacard.fields.TextField +import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote +import com.ichi2.testutils.grantPermissions +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import java.io.File +import kotlin.test.assertIs + +@RunWith(AndroidJUnit4::class) +class MultimediaActivityTest : RobolectricTest() { + @Test + fun `note context and result survive recreation and process death`() { + grantPermissions(RECORD_AUDIO) + val intent = largeNoteIntent().apply { replaceExtras(extras!!.parcelCopy()) } + val controller = startActivityControllerNormallyOpenCollectionWithIntent(MultimediaActivity::class.java, intent) + + controller.recreate() + advanceRobolectricLooper() + assertNotePreview(controller.get()) + + val savedState = Bundle() + controller + .pause() + .stop() + .saveInstanceState(savedState) + .destroy() + val restored = restoreActivity(intent, savedState) + val activity = restored.get() + assertNotePreview(activity) + + val fragment = activity.supportFragmentManager.findFragmentById(R.id.fragment_container) as AudioRecordingFragment + val recording = File(targetContext.cacheDir, "recording.ogg").apply { writeText("audio") } + fragment.viewModel.updateCurrentMultimediaPath(recording) + fragment.viewModel.updateMediaFileLength(recording.length()) + advanceRobolectricLooper() + fragment.binding.actionDone.performClick() + + val shadow = shadowOf(activity) + val result = assertIs(MultimediaResultContract().parseResult(shadow.resultCode, shadow.resultIntent)) + assertThat(result.fieldIndex, equalTo(1)) + assertIs(result.field) + assertThat(result.field.mediaFile, equalTo(recording)) + assertThat(result.field.hasTemporaryMedia, equalTo(true)) + restored.pause().stop().destroy() + } + + private fun restoreActivity( + intent: Intent, + savedState: Bundle, + ): ActivityController { + val restoredState = savedState.parcelCopy() + return Robolectric + .buildActivity(MultimediaActivity::class.java, Intent(intent).apply { replaceExtras(intent.extras!!.parcelCopy()) }) + .create(restoredState) + .start() + .restoreInstanceState(restoredState) + .postCreate(restoredState) + .resume() + .visible() + .also { + saveControllerForCleanup(it) + advanceRobolectricLooper() + } + } + + private fun assertNotePreview(activity: MultimediaActivity) { + val fragment = activity.supportFragmentManager.findFragmentById(R.id.fragment_container) as AudioRecordingFragment + val texts = + fragment.binding.audioRecorderLayout.descendants + .filterIsInstance() + .map { it.text.toString() } + .toList() + assertThat(texts.contains("front".repeat(30_000)), equalTo(true)) + assertThat(texts.contains("back"), equalTo(true)) + } + + private fun largeNoteIntent(): Intent { + val note = + MultimediaEditableNote().apply { + setNumFields(2) + setField(0, TextField().apply { text = "front".repeat(30_000) }) + setField(1, TextField().apply { text = "back" }) + freezeInitialFieldValues() + } + return AudioRecordingFragment.getIntent( + targetContext, + MultimediaActivityExtra(index = 1, field = AudioRecordingField(), note = note), + ) + } + + private fun Bundle.parcelCopy(): Bundle { + val parcel = Parcel.obtain() + return try { + parcel.writeBundle(this) + parcel.setDataPosition(0) + parcel.readBundle(MultimediaActivity::class.java.classLoader)!! + } finally { + parcel.recycle() + } + } +} From 14fbbb44f885c74ad21728ddb28e6724cc5adcb0 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:25:58 +0100 Subject: [PATCH 15/54] refactor(multimedia): load inputs before creating media views Initialize input in onCreateView, before creating the editor's view. Ensure all callbacks and cleanup handle a missing view. Part of 21953 Assisted-by: GPT-6 --- .../anki/multimedia/AudioRecordingFragment.kt | 1 + .../anki/multimedia/AudioVideoFragment.kt | 5 ++- .../anki/multimedia/MultimediaFragment.kt | 39 ++++++++++--------- .../multimedia/MultimediaImageFragment.kt | 4 ++ 4 files changed, 29 insertions(+), 20 deletions(-) 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 45197de55074..b794c4a76dd3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioRecordingFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioRecordingFragment.kt @@ -58,6 +58,7 @@ class AudioRecordingFragment : MultimediaFragment(R.layout.fragment_audio_record registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { isGranted -> + if (view == null) return@registerForActivityResult if (isGranted) { Timber.d("Audio permission granted") initializeAudioRecorder() 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 f5dc86c96d70..6d012c69ecca 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt @@ -64,6 +64,7 @@ class AudioVideoFragment : MultimediaFragment(R.layout.fragment_audio_video) { */ private val pickMediaLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (view == null) return@registerForActivityResult when { result.resultCode != Activity.RESULT_OK || result.data == null -> { Timber.d("Uri is empty or Result not OK") @@ -366,13 +367,13 @@ class AudioVideoFragment : MultimediaFragment(R.layout.fragment_audio_video) { override fun onDestroyView() { super.onDestroyView() Timber.d("Releasing media player") - mediaPlayer.release() + if (::mediaPlayer.isInitialized) mediaPlayer.release() } override fun onStop() { super.onStop() Timber.d("Stopping media player") - mediaPlayer.playWhenReady = false + if (::mediaPlayer.isInitialized) mediaPlayer.playWhenReady = false } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt index 0a6dc42bac2b..3eba46a2e92f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt @@ -6,8 +6,10 @@ package com.ichi2.anki.multimedia import android.net.Uri import android.os.Bundle import android.text.format.Formatter +import android.view.LayoutInflater import android.view.MenuItem import android.view.View +import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.annotation.DrawableRes import androidx.annotation.LayoutRes @@ -64,6 +66,25 @@ abstract class MultimediaFragment( protected lateinit var note: IMultimediaEditableNote protected var imageUri: Uri? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + val multimediaActivityExtra = + arguments?.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS) + if (multimediaActivityExtra != null) { + indexValue = multimediaActivityExtra.index + field = multimediaActivityExtra.field + note = multimediaActivityExtra.note + if (multimediaActivityExtra.imageUri != null) { + imageUri = multimediaActivityExtra.imageUri.toUri() + } + } + + return super.onCreateView(inflater, container, savedInstanceState) + } + @NeedsTest("test discard dialog shown in case there are changes") override fun onViewCreated( view: View, @@ -74,24 +95,6 @@ abstract class MultimediaFragment( requireAnkiActivity().setToolbarText(title = title) - if (arguments != null) { - Timber.d("Getting MultimediaActivityExtra values from arguments") - @Suppress("USELESS_CAST") - val multimediaActivityExtra = - arguments?.getSerializableCompat( - MultimediaActivity.EXTRA_FRAGMENT_ARGS, - ) - - if (multimediaActivityExtra != null) { - indexValue = multimediaActivityExtra.index - field = multimediaActivityExtra.field - note = multimediaActivityExtra.note - if (multimediaActivityExtra.imageUri != null) { - imageUri = multimediaActivityExtra.imageUri.toUri() - } - } - } - val backCallback = object : OnBackPressedCallback( enabled = viewModel.currentMultimediaPath.value != null, 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 9b6b2075fb47..15729873606a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt @@ -93,6 +93,7 @@ class MultimediaImageFragment : */ private val pickImageLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (view == null) return@registerForActivityResult hasStartedImageSelection = false when (result.resultCode) { Activity.RESULT_CANCELED -> { @@ -122,6 +123,7 @@ class MultimediaImageFragment : */ private val drawingActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (view == null) return@registerForActivityResult when (result.resultCode) { Activity.RESULT_CANCELED -> { // If user didn't draw, return the indexValue as a result and finish the activity @@ -143,6 +145,7 @@ class MultimediaImageFragment : @NeedsTest("Works fine without permission as we use Camera as feature") private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { isPictureTaken -> + if (view == null) return@registerForActivityResult hasStartedImageSelection = false when { !isPictureTaken && viewModel.currentMultimediaUri.value == null -> { @@ -164,6 +167,7 @@ class MultimediaImageFragment : /** Launches an activity to crop the image, using the [ImageCropper] */ private val imageCropperLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (view == null) return@registerForActivityResult hasStartedImageSelection = false when (result.resultCode) { Activity.RESULT_OK -> { From 23e8a2f63bd81ac984450dece939859515accb14 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:26:31 +0100 Subject: [PATCH 16/54] fix(multimedia): use temp file in saved state This means we no longer serialize the whole note to instance state. The fragment is responsible for the file lifetime. Fixes part of 21953 Assisted-by: GPT-6  Conflicts:  AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt --- .../java/com/ichi2/anki/NoteEditorFragment.kt | 2 +- .../anki/multimedia/MultimediaActivity.kt | 7 +- .../anki/multimedia/MultimediaArgsStorage.kt | 32 ++++ .../anki/multimedia/MultimediaFragment.kt | 25 ++- .../NoteEditorMultimediaController.kt | 6 +- .../anki/multimedia/MultimediaActivityTest.kt | 149 ++++++++++++++++++ .../NoteEditorMultimediaControllerTest.kt | 33 ++++ 7 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaArgsStorage.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 1be288cb4232..fc2cf7cb8acf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -818,7 +818,7 @@ class NoteEditorFragment : contents?.let { setEditFieldTexts(it) } tags?.let { setTags(it) } // If the activity was called to handle an image addition, launch a coroutine to process the image intent. - if (caller == NoteEditorCaller.ADD_IMAGE) lifecycleScope.launch { multimediaController.handleImageIntent(intent) } + if (caller == NoteEditorCaller.ADD_IMAGE) launchCatchingTask { multimediaController.handleImageIntent(intent) } } else { // Intercept spinner clicks to launch ChangeNoteTypeDialog instead of spinner dropdown noteTypeSpinner!!.setOnTouchListener { _, event -> 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 4c9723fad986..a6b45303d23f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt @@ -30,6 +30,7 @@ import com.ichi2.themes.Themes import com.ichi2.utils.FragmentFactoryUtils import dev.androidbroadcast.vbpd.viewBinding import timber.log.Timber +import java.io.File import java.io.Serializable import kotlin.reflect.KClass import kotlin.reflect.jvm.jvmName @@ -61,7 +62,7 @@ class MultimediaActivity : override val analyticsScreenName: String get() = intent.getStringExtra(EXTRA_FRAGMENT_NAME)?.substringAfterLast('.') ?: super.analyticsScreenName - private val Intent.multimediaArgsExtra: MultimediaActivityExtra? + private val Intent.multimediaArgsFile: File? get() = extras?.getSerializableCompat(EXTRA_FRAGMENT_ARGS) private val Intent.mediaOptionsExtra: Serializable? @@ -98,7 +99,7 @@ class MultimediaActivity : FragmentFactoryUtils.instantiate(this, fragmentClassName).apply { arguments = Bundle().apply { - putSerializable(EXTRA_FRAGMENT_ARGS, intent.multimediaArgsExtra) + putSerializable(EXTRA_FRAGMENT_ARGS, intent.multimediaArgsFile) putSerializable(EXTRA_MEDIA_OPTIONS, intent.mediaOptionsExtra) } } @@ -153,7 +154,7 @@ class MultimediaActivity : mediaOptions: Serializable? = null, ): Intent = Intent(context, MultimediaActivity::class.java).apply { - putExtra(EXTRA_FRAGMENT_ARGS, arguments) + putExtra(EXTRA_FRAGMENT_ARGS, arguments?.let { MultimediaArgsStorage.create(context).save(it) }) putExtra(EXTRA_FRAGMENT_NAME, fragmentClass.jvmName) putExtra(EXTRA_MEDIA_OPTIONS, mediaOptions) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaArgsStorage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaArgsStorage.kt new file mode 100644 index 000000000000..ccf9710f25ae --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaArgsStorage.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.multimedia + +import android.content.Context +import java.io.File +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +/** Stores note contents outside intents and saved state, which have a shared Binder size limit. */ +internal class MultimediaArgsStorage private constructor( + private val directory: File, +) { + fun save(arguments: MultimediaActivityExtra): File = + File.createTempFile(ARGS_FILE_PREFIX, ".tmp", directory).also { file -> + try { + ObjectOutputStream(file.outputStream().buffered()).use { it.writeObject(arguments) } + } catch (e: Exception) { + file.delete() + throw e + } + } + + fun read(file: File): MultimediaActivityExtra = + ObjectInputStream(file.inputStream().buffered()).use { it.readObject() as MultimediaActivityExtra } + + companion object { + private const val ARGS_FILE_PREFIX = "multimedia-args" + + fun create(context: Context): MultimediaArgsStorage = MultimediaArgsStorage(context.cacheDir) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt index 3eba46a2e92f..159bec820410 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt @@ -59,6 +59,8 @@ abstract class MultimediaFragment( val viewModel: MultimediaViewModel by viewModels() + private val argsStorage by lazy { MultimediaArgsStorage.create(requireContext()) } + protected var ankiCacheDirectory: String? = null protected var indexValue: Int = 0 @@ -72,7 +74,15 @@ abstract class MultimediaFragment( savedInstanceState: Bundle?, ): View? { val multimediaActivityExtra = - arguments?.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS) + try { + arguments + ?.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS) + ?.let(argsStorage::read) + } catch (e: Exception) { + Timber.w(e, "Unable to load multimedia arguments") + showErrorDialog() + return null + } if (multimediaActivityExtra != null) { indexValue = multimediaActivityExtra.index field = multimediaActivityExtra.field @@ -85,6 +95,14 @@ abstract class MultimediaFragment( return super.onCreateView(inflater, container, savedInstanceState) } + override fun onDestroy() { + super.onDestroy() + // Keep the input while the fragment can be restored after recreation or process death. + if (isRemoving || activity?.isFinishing == true) { + arguments?.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)?.delete() + } + } + @NeedsTest("test discard dialog shown in case there are changes") override fun onViewCreated( view: View, @@ -188,8 +206,8 @@ abstract class MultimediaFragment( /** * Creates and shows an AlertDialog with an error message - * from the application's resources. The dialog includes an "OK" button that, - * when clicked, finishes the current activity. + * from the application's resources. Acknowledging or cancelling the dialog finishes + * the current activity. */ fun showErrorDialog(errorMessage: String? = null) { AlertDialog.Builder(requireContext()).show { @@ -197,6 +215,7 @@ abstract class MultimediaFragment( setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> requireActivity().finish() } + setOnCancelListener { requireActivity().finish() } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt index 89bd78229e16..17f093bacc73 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt @@ -11,7 +11,6 @@ import android.widget.EditText import androidx.activity.result.ActivityResultLauncher import androidx.annotation.VisibleForTesting import androidx.core.os.BundleCompat -import androidx.lifecycle.lifecycleScope import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.MediaRegistration import com.ichi2.anki.NoteEditorFragment @@ -31,7 +30,6 @@ import com.ichi2.utils.ContentResolverUtil import com.ichi2.utils.openInputStreamSafe import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import timber.log.Timber import java.io.File @@ -59,9 +57,9 @@ internal class NoteEditorMultimediaController( fun handleActions(fieldIndex: Int) { actionJob?.cancel() actionJob = - fragment.lifecycleScope.launch { + fragment.launchCatchingTask { val note = fragment.getCurrentMultimediaEditableNote() - if (note.isEmpty) return@launch + if (note.isEmpty) return@launchCatchingTask fragment.multimediaViewModel.multimediaAction.first { action -> Timber.i("Selected multimedia action: %s", action) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaActivityTest.kt index 12dbaade033c..92e0c73986a1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaActivityTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaActivityTest.kt @@ -3,38 +3,163 @@ package com.ichi2.anki.multimedia import android.Manifest.permission.RECORD_AUDIO +import android.content.DialogInterface import android.content.Intent +import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Bundle import android.os.Parcel import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.core.view.descendants +import androidx.lifecycle.Lifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.multimediacard.fields.AudioRecordingField import com.ichi2.anki.multimediacard.fields.TextField import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote +import com.ichi2.testutils.AnkiFragmentScenario import com.ichi2.testutils.grantPermissions +import com.ichi2.testutils.launchFragmentInContainer +import com.ichi2.testutils.withFragment import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.lessThan +import org.hamcrest.Matchers.nullValue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ActivityController +import org.robolectric.shadows.ShadowDialog import java.io.File import kotlin.test.assertIs @RunWith(AndroidJUnit4::class) class MultimediaActivityTest : RobolectricTest() { + @Test + fun `fragment loads and releases its arguments independently of the host`() { + val args = largeNoteIntent().extras!!.parcelCopy() + val argsFile = args.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)!! + + withAudioRecordingFragment(args) { scenario -> + scenario.withFragment { assertNotePreview(this) } + scenario.recreate() + scenario.withFragment { assertNotePreview(this) } + scenario.moveToState(Lifecycle.State.DESTROYED) + } + + assertThat(argsFile.exists(), equalTo(false)) + } + + @Test + fun `older fragments can be recreated after a newer launch`() { + val args = largeNoteIntent().extras!!.parcelCopy() + val argsFile = args.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)!! + lateinit var newArgs: Bundle + + withAudioRecordingFragment(args) { scenario -> + newArgs = largeNoteIntent().extras!!.parcelCopy() + assertThat(argsFile.exists(), equalTo(true)) + scenario.recreate() + scenario.withFragment { assertNotePreview(this) } + } + withAudioRecordingFragment(newArgs) { scenario -> + scenario.withFragment { assertNotePreview(this) } + } + } + + @Test + fun `closing an older fragment preserves the arguments of a newer launch`() { + lateinit var newArgs: Bundle + withAudioRecordingFragment(largeNoteIntent().extras!!) { scenario -> + newArgs = largeNoteIntent().extras!!.parcelCopy() + scenario.moveToState(Lifecycle.State.DESTROYED) + } + + withAudioRecordingFragment(newArgs) { scenario -> + scenario.withFragment { assertNotePreview(this) } + } + } + + @Test + fun `launching multimedia with a large note keeps the intent small`() { + assertThat(largeNoteIntent().extras!!.parcelSize(), lessThan(64 * 1024)) + } + + @Test + fun `saving multimedia with a large note keeps the activity state small`() { + grantPermissions(RECORD_AUDIO) + val controller = + startActivityControllerNormallyOpenCollectionWithIntent( + MultimediaActivity::class.java, + largeNoteIntent(), + ) + val savedState = Bundle() + controller.pause().stop().saveInstanceState(savedState) + + assertThat(savedState.parcelSize(), lessThan(64 * 1024)) + } + + @Test + fun `missing cached arguments show an error instead of restoring the editor`() { + val intent = largeNoteIntent() + val controller = startActivityControllerNormallyOpenCollectionWithIntent(MultimediaActivity::class.java, intent) + val permissionRequest = shadowOf(controller.get()).lastRequestedPermission + val savedState = Bundle() + controller + .pause() + .stop() + .saveInstanceState(savedState) + .destroy() + intent.extras!!.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)!!.delete() + + val activity = restoreActivity(intent, savedState).get() + + assertThat(activity.supportFragmentManager.findFragmentById(R.id.fragment_container)!!.view, nullValue()) + activity.onRequestPermissionsResult(permissionRequest.requestCode, arrayOf(RECORD_AUDIO), intArrayOf(PERMISSION_GRANTED)) + val dialog = ShadowDialog.getLatestDialog() as AlertDialog + dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick() + advanceRobolectricLooper() + assertThat(activity.isFinishing, equalTo(true)) + } + + @Test + fun `cancelling the missing arguments dialog closes the empty activity`() { + val intent = largeNoteIntent() + intent.extras!!.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)!!.delete() + val activity = startActivityControllerNormallyOpenCollectionWithIntent(MultimediaActivity::class.java, intent).get() + assertThat(activity.supportFragmentManager.findFragmentById(R.id.fragment_container)!!.view, nullValue()) + + ShadowDialog.getLatestDialog().cancel() + advanceRobolectricLooper() + + assertThat(activity.isFinishing, equalTo(true)) + } + + @Test + fun `missing arguments allow the audio video fragment to close without a player`() { + val args = largeNoteIntent().extras!!.parcelCopy() + args.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)!!.delete() + args.putSerializable(MultimediaActivity.EXTRA_MEDIA_OPTIONS, AudioVideoFragment.MediaOption.AUDIO_CLIP) + + launchFragmentInContainer(args).use { scenario -> + scenario.withFragment { assertThat(view, nullValue()) } + scenario.moveToState(Lifecycle.State.DESTROYED) + } + } + @Test fun `note context and result survive recreation and process death`() { grantPermissions(RECORD_AUDIO) val intent = largeNoteIntent().apply { replaceExtras(extras!!.parcelCopy()) } + val argsFile = intent.extras!!.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)!! val controller = startActivityControllerNormallyOpenCollectionWithIntent(MultimediaActivity::class.java, intent) controller.recreate() advanceRobolectricLooper() + assertThat(argsFile.exists(), equalTo(true)) assertNotePreview(controller.get()) val savedState = Bundle() @@ -43,6 +168,7 @@ class MultimediaActivityTest : RobolectricTest() { .stop() .saveInstanceState(savedState) .destroy() + assertThat(argsFile.exists(), equalTo(true)) val restored = restoreActivity(intent, savedState) val activity = restored.get() assertNotePreview(activity) @@ -61,6 +187,15 @@ class MultimediaActivityTest : RobolectricTest() { assertThat(result.field.mediaFile, equalTo(recording)) assertThat(result.field.hasTemporaryMedia, equalTo(true)) restored.pause().stop().destroy() + assertThat("finished activities release the note data", argsFile.exists(), equalTo(false)) + } + + private fun withAudioRecordingFragment( + args: Bundle, + block: (AnkiFragmentScenario) -> Unit, + ) { + grantPermissions(RECORD_AUDIO) + launchFragmentInContainer(args).use(block) } private fun restoreActivity( @@ -84,6 +219,10 @@ class MultimediaActivityTest : RobolectricTest() { private fun assertNotePreview(activity: MultimediaActivity) { val fragment = activity.supportFragmentManager.findFragmentById(R.id.fragment_container) as AudioRecordingFragment + assertNotePreview(fragment) + } + + private fun assertNotePreview(fragment: AudioRecordingFragment) { val texts = fragment.binding.audioRecorderLayout.descendants .filterIsInstance() @@ -107,6 +246,16 @@ class MultimediaActivityTest : RobolectricTest() { ) } + private fun Bundle.parcelSize(): Int { + val parcel = Parcel.obtain() + return try { + parcel.writeBundle(this) + parcel.dataSize() + } finally { + parcel.recycle() + } + } + private fun Bundle.parcelCopy(): Bundle { val parcel = Parcel.obtain() return try { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt index b6dec4214771..d867beaf7c11 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt @@ -8,16 +8,25 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.NoteEditorFragment import com.ichi2.anki.RobolectricTest import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.anki.multimedia.MultimediaArgsStorage +import com.ichi2.anki.multimedia.MultimediaBottomSheet import com.ichi2.anki.multimedia.MultimediaResult import com.ichi2.anki.multimediacard.fields.ImageField import com.ichi2.anki.noteeditor.NoteEditorMultimediaController.Companion.STATE_KEY_IMAGE_CACHE +import com.ichi2.anki.throwOnShowError +import io.mockk.every +import io.mockk.mockkConstructor +import io.mockk.verify import kotlinx.coroutines.test.advanceUntilIdle import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.notNullValue import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDialog import java.io.File +import java.io.IOException @RunWith(AndroidJUnit4::class) class NoteEditorMultimediaControllerTest : RobolectricTest() { @@ -27,6 +36,30 @@ class NoteEditorMultimediaControllerTest : RobolectricTest() { assertThat(noteEditor.multimediaController, notNullValue()) } + @Test + fun `argument write failure shows an error and preserves the note editor`() { + val noteEditor = openNoteEditor() + noteEditor.setField(0, "front") + noteEditor.multimediaController.handleActions(0) + advanceRobolectricLooper() + throwOnShowError = false + // Directory permissions cannot reliably force a write failure on every platform. + mockkConstructor(MultimediaArgsStorage::class) { + every { anyConstructed().save(any()) } throws IOException("Unable to write multimedia arguments") + + noteEditor.multimediaViewModel.setMultimediaAction(MultimediaBottomSheet.MultimediaAction.SELECT_AUDIO_RECORDING) + advanceRobolectricLooper() + verify(exactly = 1) { anyConstructed().save(any()) } + } + + val dialog = ShadowDialog.getLatestDialog() + assertThat(dialog, notNullValue()) + assertThat(dialog.isShowing, equalTo(true)) + assertThat(noteEditor.requireActivity().isFinishing, equalTo(false)) + assertThat(noteEditor.editFieldAt(0)!!.text.toString(), equalTo("front")) + assertThat(shadowOf(noteEditor.requireActivity()).nextStartedActivity, equalTo(null)) + } + @Test fun `onSaveInstanceState writes the imageCache key`() { val noteEditor = openNoteEditor() From 43e7515ba05fea922300e79178da511cc9c41b0e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:18:30 +0100 Subject: [PATCH 17/54] fix(multimedia): clear some caches on startup * orphaned multimedia arg files - after 7 days Related to 21953 Assisted-by: GPT-6 --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 13 ++++ .../anki/multimedia/MultimediaArgsStorage.kt | 20 ++++++ .../multimedia/MultimediaArgsStorageTest.kt | 66 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaArgsStorageTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 699330bdb867..897b1b3d7d58 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -51,6 +51,7 @@ import com.ichi2.anki.logging.ProductionCrashReportingTree import com.ichi2.anki.logging.RobolectricDebugTree import com.ichi2.anki.logging.logActivityCreation import com.ichi2.anki.model.FieldFilters.NoSuggestFilter +import com.ichi2.anki.multimedia.MultimediaArgsStorage import com.ichi2.anki.navigation.initializeNavigator import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.preferences.SharedPreferencesProvider @@ -74,6 +75,7 @@ import com.ichi2.widget.WidgetNotificationScheduler import com.ichi2.widget.cardanalysis.CardAnalysisWidget import com.ichi2.widget.deckpicker.DeckPickerWidget import com.ichi2.widget.restoreRecurringAlarms +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import timber.log.Timber import timber.log.Timber.DebugTree @@ -172,6 +174,7 @@ open class AnkiDroidApp : Timber.d("Skipping AnkiDroidApp.onCreate from ACRA sender process") return } + launchCacheCleanup() if (AdaptionUtil.isUserATestClient) { showThemedToast(this.applicationContext, getString(R.string.user_is_a_robot), false) } @@ -216,6 +219,16 @@ open class AnkiDroidApp : super.onTerminate() } + /** Schedules cache maintenance and logs its duration without delaying startup. */ + private fun launchCacheCleanup() = + applicationScope.launch(Dispatchers.IO) { + runCatching { + measureTime("cacheCleanup") { + MultimediaArgsStorage.create(this@AnkiDroidApp).removeExpiredFiles() + } + }.onFailure { Timber.w(it, "Unable to clean up expired cache files") } + } + /** * @param debugTraceSqlCalls Log all SQL statements executed by the backend. * **Warning** The log may be delayed by 100ms, so you should not assume than a given SQL diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaArgsStorage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaArgsStorage.kt index ccf9710f25ae..0131547e4b40 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaArgsStorage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaArgsStorage.kt @@ -3,9 +3,12 @@ package com.ichi2.anki.multimedia import android.content.Context +import com.ichi2.anki.common.time.TimeManager +import timber.log.Timber import java.io.File import java.io.ObjectInputStream import java.io.ObjectOutputStream +import kotlin.time.Duration.Companion.days /** Stores note contents outside intents and saved state, which have a shared Binder size limit. */ internal class MultimediaArgsStorage private constructor( @@ -24,8 +27,25 @@ internal class MultimediaArgsStorage private constructor( fun read(file: File): MultimediaActivityExtra = ObjectInputStream(file.inputStream().buffered()).use { it.readObject() as MultimediaActivityExtra } + /** + * Reclaims arguments left behind when a screen could not run its cleanup, such as after process death. + * Called at startup on an IO dispatcher. Saved screens older than [FILE_RETENTION] may no longer restore. + */ + fun removeExpiredFiles(now: Long = TimeManager.time.intTimeMS()) { + val cutoff = now - FILE_RETENTION.inWholeMilliseconds + directory + .listFiles { _, name -> name.startsWith(ARGS_FILE_PREFIX) && name.endsWith(".tmp") } + ?.forEach { file -> + val modified = file.lastModified() + if (file.isFile && modified > 0 && modified < cutoff && !file.delete()) { + Timber.w("Unable to delete expired multimedia arguments: %s", file) + } + } + } + companion object { private const val ARGS_FILE_PREFIX = "multimedia-args" + private val FILE_RETENTION = 7.days fun create(context: Context): MultimediaArgsStorage = MultimediaArgsStorage(context.cacheDir) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaArgsStorageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaArgsStorageTest.kt new file mode 100644 index 000000000000..15e5deb1c7bd --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaArgsStorageTest.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.multimedia + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds + +class MultimediaArgsStorageTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val now = 1_800_000_000_000L + + @Test + fun `cleanup removes only arguments older than seven days`() { + val expired = createFile("multimedia-args-expired.tmp", 7.days + 1.seconds) + val atCutoff = createFile("multimedia-args-cutoff.tmp", 7.days) + val recent = createFile("multimedia-args-recent.tmp", 1.days) + val future = createFile("multimedia-args-future.tmp", (-1).days) + + createStorage().removeExpiredFiles(now) + + assertFalse(expired.exists()) + assertTrue(atCutoff.exists()) + assertTrue(recent.exists()) + assertTrue(future.exists()) + } + + @Test + fun `cleanup preserves unrelated files and directories even when expired`() { + val unrelated = createFile("ids123.tmp", 8.days) + val differentSuffix = createFile("multimedia-args123.json", 8.days) + val directory = temporaryFolder.newFolder("multimedia-args-directory.tmp") + assertTrue(directory.setLastModified(now - 8.days.inWholeMilliseconds)) + + createStorage().removeExpiredFiles(now) + + assertTrue(unrelated.exists()) + assertTrue(differentSuffix.exists()) + assertTrue(directory.isDirectory) + } + + private fun createStorage(): MultimediaArgsStorage { + val context = mockk() + every { context.cacheDir } returns temporaryFolder.root + return MultimediaArgsStorage.create(context) + } + + private fun createFile( + name: String, + age: Duration, + ): File = + temporaryFolder.newFile(name).apply { + assertTrue(setLastModified(now - age.inWholeMilliseconds)) + } +} From b4a033db8d0df8eb63105e5508f207c37f00f1e5 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:47:17 +0100 Subject: [PATCH 18/54] feat(libanki): implement addNotes https://github.com/ankitects/anki/blob/754ce3a25f608010c0249e074e5d7fe95bda035f/pylib/anki/collection.py#L544-L558 Assisted-by: GPT-6 --- .../java/com/ichi2/anki/libanki/Collection.kt | 47 +++++++--- .../main/java/com/ichi2/anki/libanki/Note.kt | 2 +- .../com/ichi2/anki/libanki/CollectionTest.kt | 89 +++++++++++++++++++ 3 files changed, 126 insertions(+), 12 deletions(-) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt index 757d958c5ee5..ab99b1be7580 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt @@ -46,7 +46,7 @@ import anki.import_export.ImportAnkiPackageOptions import anki.import_export.ImportCsvRequest import anki.import_export.ImportResponse import anki.import_export.csvMetadataRequest -import anki.notes.AddNoteRequest +import anki.notes.addNoteRequest import anki.scheduler.stateOrNull import anki.search.BrowserColumns import anki.search.BrowserRow @@ -104,6 +104,17 @@ data class ComputedMemoryState( val decay: Float? = null, ) +/** + * [Upstream Python definition](https://github.com/ankitects/anki/blob/754ce3a25f608010c0249e074e5d7fe95bda035f/pylib/anki/collection.py#L132-L135). + * + * Converted to the generated protobuf [anki.notes.AddNoteRequest] for backend calls. + */ +@LibAnkiAlias("AddNoteRequest") +data class AddNoteRequest( + val note: Note, + val deckId: DeckId, +) + // Anki maintains a cache of used tags so it can quickly present a list of tags // for autocomplete and in the browser. For efficiency, deletions are not // tracked, so unused tags can only be removed from the list with a DB check. @@ -687,17 +698,31 @@ class Collection( return out.changes } + /** + * Add notes in a single undoable operation, updating their IDs on success. + * + * - [`pylib` implementation](https://github.com/ankitects/anki/blob/754ce3a25f608010c0249e074e5d7fe95bda035f/pylib/anki/collection.py#L544-L558) + * + * @throws net.ankiweb.rsdroid.exceptions.BackendInvalidInputException if any notes are invalid. + * The batch is rolled back if this occurs. + */ @LibAnkiAlias("add_notes") - @RustCleanup("Implement") - @Deprecated("Needs implementation", level = DeprecationLevel.HIDDEN) - fun addNotes(requests: List): OpChanges? = TODO() -// { -// val out = backend.addNotes(requests = requests) -// for ((idx, request) in requests.withIndex()) { -// request.note!!.id = out.getNids(idx) -// } -// return out.changes -// } + fun addNotes(requests: List): OpChanges { + val out = + backend.addNotes( + requests = + requests.map { request -> + addNoteRequest { + note = request.note.toBackendNote() + deckId = request.deckId + } + }, + ) + for ((idx, request) in requests.withIndex()) { + request.note.id = out.getNids(idx) + } + return out.changes + } @LibAnkiAlias("remove_notes") @RustCleanup("remove cids and pass in []") diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Note.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Note.kt index 927da2a9cca1..a0664106ce74 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Note.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Note.kt @@ -31,7 +31,7 @@ import java.util.regex.Pattern @KotlinCleanup("lots to do") class Note : Cloneable { /** - * Should only be mutated by addNote() + * Should only be mutated by [Collection.addNote] or [Collection.addNotes]. */ var id: NoteId = 0L diff --git a/libanki/src/test/java/com/ichi2/anki/libanki/CollectionTest.kt b/libanki/src/test/java/com/ichi2/anki/libanki/CollectionTest.kt index bc34c5165d3a..d238a0fc2772 100644 --- a/libanki/src/test/java/com/ichi2/anki/libanki/CollectionTest.kt +++ b/libanki/src/test/java/com/ichi2/anki/libanki/CollectionTest.kt @@ -21,6 +21,7 @@ import com.ichi2.anki.libanki.testutils.InMemoryAnkiTest import com.ichi2.anki.libanki.testutils.ext.addNote import com.ichi2.anki.libanki.testutils.ext.createBasicNoteType import com.ichi2.anki.libanki.testutils.ext.newNote +import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers import org.hamcrest.Matchers.equalTo @@ -30,11 +31,99 @@ import org.hamcrest.Matchers.nullValue import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Ignore import org.junit.Test class CollectionTest : InMemoryAnkiTest() { + @Test + fun `addNotes adds notes to their requested decks in a single undo step`() { + val basicNote = + col.newNote(col.notetypes.basic).apply { + setItem("Front", "one") + setItem("Back", "two") + tags.add("basic") + } + val reversedNote = + col.newNote(col.notetypes.basicAndReversed).apply { + setItem("Front", "three") + setItem("Back", "four") + tags.add("reversed") + } + val requests = + listOf( + AddNoteRequest(basicNote, Consts.DEFAULT_DECK_ID), + AddNoteRequest(reversedNote, addDeck("Other")), + ) + + val changes = col.addNotes(requests) + + assertTrue(changes.note) + assertTrue(changes.card) + assertEquals(2, col.noteCount()) + assertEquals(3, col.cardCount()) + assertNotEquals(basicNote.id, reversedNote.id) + for ((note, deckId) in requests) { + assertNotEquals(0L, note.id) + val storedNote = col.getNote(note.id) + assertEquals(note.fields, storedNote.fields) + assertEquals(note.tags, storedNote.tags) + assertEquals(note.noteTypeId, storedNote.noteTypeId) + assertTrue(note.cards().all { it.did == deckId }) + } + assertEquals(1, basicNote.numberOfCards()) + assertEquals(2, reversedNote.numberOfCards()) + + col.undo() + assertEquals(0, col.noteCount()) + assertEquals(0, col.cardCount()) + + col.redo() + assertEquals(2, col.noteCount()) + assertEquals(3, col.cardCount()) + assertEquals(basicNote.fields, col.getNote(basicNote.id).fields) + assertEquals(reversedNote.fields, col.getNote(reversedNote.id).fields) + } + + @Test + fun `addNotes with no requests leaves the collection unchanged`() { + val note = addBasicNote() + + val changes = col.addNotes(emptyList()) + + assertFalse(changes.note) + assertFalse(changes.card) + assertEquals(1, col.noteCount()) + assertEquals(1, col.cardCount()) + assertEquals(note.fields, col.getNote(note.id).fields) + + col.undo() + assertEquals(0, col.noteCount()) + assertEquals(0, col.cardCount()) + } + + @Test + fun `addNotes failure rolls back the batch without assigning note IDs`() { + val validNote = col.newNote().apply { setItem("Front", "valid") } + val invalidNote = col.newNote().apply { fields.removeAt(1) } + + assertThrows(BackendInvalidInputException::class.java) { + col.addNotes( + listOf( + AddNoteRequest(validNote, Consts.DEFAULT_DECK_ID), + AddNoteRequest(invalidNote, Consts.DEFAULT_DECK_ID), + ), + ) + } + + assertEquals(0L, validNote.id) + assertEquals(0L, invalidNote.id) + assertEquals(0, col.noteCount()) + assertEquals(0, col.cardCount()) + assertFalse(col.undoAvailable()) + } + @Test fun editClozeGenerateCardsInSameDeck() { // #7781 From 4b8fcbe7e210b45e52cabd274457c5d31ed0a924 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:10:02 +0100 Subject: [PATCH 19/54] test(reminders): cover notification permission flow - creating a reminder, dismissing it, and recreation (which doesn't prompt) - when the permission is already enabled - reminder cancellation Fixes 21753 Assisted-by: GPT-6 --- ...eviewReminderNotificationPermissionTest.kt | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderNotificationPermissionTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderNotificationPermissionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderNotificationPermissionTest.kt new file mode 100644 index 000000000000..72716fb2d1a6 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderNotificationPermissionTest.kt @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.reviewreminders + +import android.app.NotificationManager +import android.os.Build +import android.view.View +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.ui.windows.permissions.PermissionsBottomSheet +import com.ichi2.testutils.positiveButton +import kotlinx.coroutines.runBlocking +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.nullValue +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf + +/** Tests AnkiDroid's permission sheet without changing the device's notification permission. */ +@RunWith(AndroidJUnit4::class) +class ReviewReminderNotificationPermissionTest : RobolectricTest() { + private var originalReminderRequestShown = false + private var originalPermissionRequested = false + private var originalLegacySheetShown = false + + @Before + fun setUpReminders() { + ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() } + originalReminderRequestShown = Prefs.reminderNotifsRequestShown + originalPermissionRequested = Prefs.notificationsPermissionRequested + originalLegacySheetShown = Prefs.notificationsBottomSheetShownBelowAPI33 + Prefs.reminderNotifsRequestShown = false + Prefs.notificationsPermissionRequested = false + Prefs.notificationsBottomSheetShownBelowAPI33 = false + shadowOf(targetContext.getSystemService()!!).setNotificationsEnabled(false) + } + + @After + fun tearDownReminders() { + Prefs.reminderNotifsRequestShown = originalReminderRequestShown + Prefs.notificationsPermissionRequested = originalPermissionRequested + Prefs.notificationsBottomSheetShownBelowAPI33 = originalLegacySheetShown + ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() } + } + + @Test + fun notificationPermissionPromptIsOnlyShownOnce() { + withScheduleRemindersFragment { fragment -> + fragment.createReminder(expectedCount = 1) + + val permissionViewId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + R.id.notification_permission + } else { + R.id.legacy_notification_permission + } + advanceRobolectricLooperUntil { + fragment.permissionsBottomSheet + ?.view + ?.findViewById(permissionViewId) + ?.isShown == true + } + assertThat(Prefs.reminderNotifsRequestShown, equalTo(true)) + fragment.permissionsBottomSheet!! + .requireView() + .findViewById(R.id.close_button) + .performClick() + advanceRobolectricLooperUntil { fragment.permissionsBottomSheet == null } + assertThat(fragment.reminderCount, equalTo(1)) + assertThat(storedReminders().size, equalTo(1)) + } + // Only the reminder-specific flag should prevent the second prompt, including below API 33. + Prefs.notificationsBottomSheetShownBelowAPI33 = false + withScheduleRemindersFragment { fragment -> + fragment.createReminder(expectedCount = 2) + + assertThat(fragment.permissionsBottomSheet, nullValue()) + } + } + + @Test + fun notificationPermissionPromptIsSkippedWhenGranted() { + shadowOf(targetContext.getSystemService()!!).setNotificationsEnabled(true) + withScheduleRemindersFragment { fragment -> + fragment.createReminder(expectedCount = 1) + + assertThat(fragment.permissionsBottomSheet, nullValue()) + assertThat( + "A sheet that was not shown must not consume the first request", + Prefs.reminderNotifsRequestShown, + equalTo(false), + ) + } + } + + @Test + fun cancelCreatingReminderDoesNotRequestNotificationPermission() { + withScheduleRemindersFragment { fragment -> + fragment + .openAddDialog() + .requireDialog() + .findViewById(android.R.id.button3) + .performClick() + advanceRobolectricLooper() + + assertThat(fragment.permissionsBottomSheet, nullValue()) + assertThat(Prefs.reminderNotifsRequestShown, equalTo(false)) + assertThat(storedReminders(), empty()) + assertThat(fragment.reminderCount, equalTo(0)) + } + } + + private fun ScheduleRemindersFragment.openAddDialog(): AddEditReminderDialog { + binding.floatingActionButtonAdd.performClick() + advanceRobolectricLooper() + return childFragmentManager.fragments.filterIsInstance().single() + } + + private fun ScheduleRemindersFragment.createReminder(expectedCount: Int) { + openAddDialog().positiveButton.performClick() + advanceRobolectricLooperUntil { storedReminders().size == expectedCount && reminderCount == expectedCount } + } + + private fun storedReminders(): List = runBlocking { ReviewRemindersDatabase.getAllReminders().getRemindersList() } + + private val ScheduleRemindersFragment.permissionsBottomSheet: PermissionsBottomSheet? + get() = childFragmentManager.fragments.filterIsInstance().singleOrNull() + + private val ScheduleRemindersFragment.reminderCount: Int + get() = binding.recyclerView.adapter!!.itemCount +} From 2cade22b55776aa89320729db8b9ff1d59946a12 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:28:36 +0100 Subject: [PATCH 20/54] refactor(test): introduce OverridePropertyRule Nicer syntax to support saving/restoring properties Assisted-by: GPT-6 --- ...eviewReminderNotificationPermissionTest.kt | 21 ++++----- .../testutils/rules/OverridePropertyRule.kt | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/rules/OverridePropertyRule.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderNotificationPermissionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderNotificationPermissionTest.kt index 72716fb2d1a6..b322999eacbb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderNotificationPermissionTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewReminderNotificationPermissionTest.kt @@ -13,6 +13,7 @@ import com.ichi2.anki.RobolectricTest import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.windows.permissions.PermissionsBottomSheet import com.ichi2.testutils.positiveButton +import com.ichi2.testutils.rules.OverridePropertyRule import kotlinx.coroutines.runBlocking import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.empty @@ -20,6 +21,7 @@ import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.nullValue import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf @@ -27,27 +29,22 @@ import org.robolectric.Shadows.shadowOf /** Tests AnkiDroid's permission sheet without changing the device's notification permission. */ @RunWith(AndroidJUnit4::class) class ReviewReminderNotificationPermissionTest : RobolectricTest() { - private var originalReminderRequestShown = false - private var originalPermissionRequested = false - private var originalLegacySheetShown = false + @get:Rule + val notificationPreferences = + OverridePropertyRule( + Prefs::reminderNotifsRequestShown to false, + Prefs::notificationsPermissionRequested to false, + Prefs::notificationsBottomSheetShownBelowAPI33 to false, + ) @Before fun setUpReminders() { ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() } - originalReminderRequestShown = Prefs.reminderNotifsRequestShown - originalPermissionRequested = Prefs.notificationsPermissionRequested - originalLegacySheetShown = Prefs.notificationsBottomSheetShownBelowAPI33 - Prefs.reminderNotifsRequestShown = false - Prefs.notificationsPermissionRequested = false - Prefs.notificationsBottomSheetShownBelowAPI33 = false shadowOf(targetContext.getSystemService()!!).setNotificationsEnabled(false) } @After fun tearDownReminders() { - Prefs.reminderNotifsRequestShown = originalReminderRequestShown - Prefs.notificationsPermissionRequested = originalPermissionRequested - Prefs.notificationsBottomSheetShownBelowAPI33 = originalLegacySheetShown ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() } } diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/rules/OverridePropertyRule.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/rules/OverridePropertyRule.kt new file mode 100644 index 000000000000..3675ce025d0a --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/rules/OverridePropertyRule.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils.rules + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import kotlin.reflect.KMutableProperty0 + +/** + * Temporarily overrides mutable properties and restores their values on test completion. + * + * ```kotlin + * @get:Rule + * val notificationPreference = + * OverridePropertyRule(Prefs::reminderNotifsRequestShown to false) + * + * // Use as normal, the original value is restored after the test. + * Prefs.reminderNotifsRequestShown = true + * ``` + * + * @param overrides Pairs of mutable property references and their temporary values. + * All properties in one rule must share the same value type [T]. + */ +class OverridePropertyRule( + private vararg val overrides: Pair, T>, +) : TestRule { + override fun apply( + base: Statement, + description: Description, + ): Statement = + object : Statement() { + override fun evaluate() { + val originalValues = overrides.map { (property, _) -> property to property.get() } + try { + overrides.forEach { (property, value) -> property.set(value) } + base.evaluate() + } finally { + originalValues.forEach { (property, value) -> property.set(value) } + } + } + } +} From c0db915dc424ad97eaa44865891604a9268dcd3d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:35:49 +0100 Subject: [PATCH 21/54] refactor(card-browser): move clearView to a callback Makes the code testable Assisted-by: Claude Fable 5.1 --- .../browser/BrowserColumnSelectionAdapter.kt | 17 ++-- .../browser/BrowserColumnSelectionFragment.kt | 11 +-- ...rColumnSelectionTouchHelperCallbackTest.kt | 86 +++++++++++++++++++ .../com/ichi2/testutils/RecyclerViewUtils.kt | 32 +++++++ .../java/com/ichi2/testutils/ViewUtils.kt | 34 ++++++++ 5 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnSelectionTouchHelperCallbackTest.kt 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 52bbec46416a..7eba16b5e343 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt @@ -91,12 +91,6 @@ class BrowserColumnSelectionAdapter( notifyItemChanged(toPosition) } - fun refreshDataset() { - // this needs to be done after onMoved, or the drag operation sometimes completes early - // when on a tablet - notifyItemRangeChanged(0, items.size) - } - fun MutableList.move( fromIndex: Int, toIndex: Int, @@ -162,7 +156,7 @@ class BrowserColumnSelectionAdapter( /** * A [ItemTouchHelper.Callback] for the [BrowserColumnSelectionAdapter]. */ -open class BrowserColumnSelectionTouchHelperCallback( +class BrowserColumnSelectionTouchHelperCallback( private val items: MutableList, ) : ItemTouchHelper.Callback() { private val movementFlags = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) @@ -193,6 +187,15 @@ open class BrowserColumnSelectionTouchHelperCallback( return true } + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) { + // this needs to be done after onMoved, or the drag operation sometimes completes early + // when on a tablet + recyclerView.adapter?.notifyItemRangeChanged(0, items.size) + } + override fun onSwiped( viewHolder: RecyclerView.ViewHolder, direction: Int, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionFragment.kt index 39a38db4368a..3e3f5b3eed85 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionFragment.kt @@ -165,16 +165,7 @@ class BrowserColumnSelectionFragment : DialogFragment(R.layout.dialog_browser_co yieldAll(available.map(::ColumnItem)) }.toMutableList() - val callback = - object : BrowserColumnSelectionTouchHelperCallback(recyclerViewItems) { - override fun clearView( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - ) { - columnAdapter.refreshDataset() - } - } - val itemTouchHelper = ItemTouchHelper(callback) + val itemTouchHelper = ItemTouchHelper(BrowserColumnSelectionTouchHelperCallback(recyclerViewItems)) this.columnAdapter = BrowserColumnSelectionAdapter(recyclerViewItems).apply { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnSelectionTouchHelperCallbackTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnSelectionTouchHelperCallbackTest.kt new file mode 100644 index 000000000000..68a1f8f6ae15 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnSelectionTouchHelperCallbackTest.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser + +import android.content.Context +import android.view.MotionEvent +import android.view.View +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.browser.BrowserColumnSelectionRecyclerItem.ColumnItem +import com.ichi2.anki.browser.BrowserColumnSelectionRecyclerItem.UsageItem +import com.ichi2.anki.browser.ColumnUsage.ACTIVE +import com.ichi2.anki.browser.ColumnUsage.AVAILABLE +import com.ichi2.testutils.changedPositions +import com.ichi2.testutils.completeDroppedItem +import com.ichi2.testutils.dispatchTouch +import com.ichi2.testutils.dropDraggedItem +import com.ichi2.testutils.withViewOnScreen +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith + +/** Tests for [BrowserColumnSelectionTouchHelperCallback] */ +@RunWith(AndroidJUnit4::class) +class BrowserColumnSelectionTouchHelperCallbackTest : RobolectricTest() { + @Test + fun `all rows are refreshed once a dropped row completes`() = + withColumnSelectionList { list -> + val changedRows = list.adapter!!.changedPositions() + list.startDrag(row = 1) + list.dropDraggedItem() + assertThat("not refreshed before drop completes", changedRows, empty()) + + list.completeDroppedItem() + + assertThat("refreshed after drop completes", changedRows, equalTo(list.allRows)) + } + + /** Displays the 'manage columns' list in a window a few rows tall, so a scroll recycles rows */ + private fun withColumnSelectionList(block: (RecyclerView) -> Unit) = + withViewOnScreen(height = LIST_HEIGHT_PX, createView = ::columnSelectionList) { list -> + check(list.canScrollVertically(1)) { "the list should not fit on screen" } + block(list) + } + + /** Two active columns and the rest available, draggable via [BrowserColumnSelectionTouchHelperCallback] */ + private fun columnSelectionList(context: Context): RecyclerView { + val columns = CardBrowserColumn.entries.map { ColumnWithSample(label = it.name, columnType = it, sampleValue = null) } + val items = + buildList { + add(UsageItem(ACTIVE)) + addAll(columns.take(2).map(::ColumnItem)) + add(UsageItem(AVAILABLE)) + addAll(columns.drop(2).map(::ColumnItem)) + }.toMutableList() + val itemTouchHelper = ItemTouchHelper(BrowserColumnSelectionTouchHelperCallback(items)) + return RecyclerView(context).apply { + layoutManager = LinearLayoutManager(context) + adapter = + BrowserColumnSelectionAdapter(items).apply { + setOnDragHandleTouchedListener { itemTouchHelper.startDrag(it) } + } + itemTouchHelper.attachToRecyclerView(this) + } + } + + /** Touches the drag handle of [row], which starts a drag */ + private fun RecyclerView.startDrag(row: Int) { + val dragHandle = findViewHolderForAdapterPosition(row)!!.itemView.findViewById(R.id.drag_handle) + dragHandle.dispatchTouch(MotionEvent.ACTION_DOWN) + } + + private val RecyclerView.allRows: Set + get() = (0 until adapter!!.itemCount).toSet() + + companion object { + /** Fits a few rows, leaving the rest off-screen */ + private const val LIST_HEIGHT_PX = 200 + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/RecyclerViewUtils.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/RecyclerViewUtils.kt index 8d7c62c0f769..7b8541e2d4df 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/RecyclerViewUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/RecyclerViewUtils.kt @@ -15,9 +15,14 @@ */ package com.ichi2.testutils +import android.os.Looper +import android.view.MotionEvent import android.view.View +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper +import org.robolectric.Shadows.shadowOf +import java.time.Duration object RecyclerViewUtils { inline fun viewHolderAt( @@ -47,3 +52,30 @@ fun RecyclerView.scrollToEnd() { while (canScrollVertically(1)) scrollBy(0, 50) advanceRobolectricLooper() } + +/** Releases an [ItemTouchHelper] drag: the item animates back into place before it settles */ +fun RecyclerView.dropDraggedItem() = dispatchTouch(MotionEvent.ACTION_UP) + +/** Completes the animation which returns a [dropped][dropDraggedItem] item into place */ +fun RecyclerView.completeDroppedItem() { + // ItemTouchHelper animates a drop for the item animator's 'move' duration + val duration = itemAnimator?.moveDuration ?: ItemTouchHelper.Callback.DEFAULT_DRAG_ANIMATION_DURATION.toLong() + // doubled: the animation starts a frame after the drop, and ends on the frame after its duration + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(duration * 2)) +} + +/** The positions of the items which the adapter reports as changed, from now on */ +fun RecyclerView.Adapter<*>.changedPositions(): Set { + val positions = mutableSetOf() + registerAdapterDataObserver( + object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeChanged( + positionStart: Int, + itemCount: Int, + ) { + positions += positionStart until positionStart + itemCount + } + }, + ) + return positions +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ViewUtils.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ViewUtils.kt index 5786424b50f2..2c13e7bcfe2a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/ViewUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ViewUtils.kt @@ -16,9 +16,14 @@ package com.ichi2.testutils +import android.content.Context import android.os.SystemClock import android.view.MotionEvent import android.view.View +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.test.core.app.ActivityScenario +import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper import com.ichi2.anki.ui.DoubleTapListener import org.robolectric.Shadows @@ -92,3 +97,32 @@ private fun obtainMotionEvent( y, metaState, )!! + +/** + * Runs [block] with a view displayed in an activity: laid out, and attached to a window so + * [View.post] executes. + * + * @param height the height of the view, in pixels. Defaults to the height of the screen + * @param createView creates the view. The activity is provided as the context, so the AnkiDroid + * theme is applied + */ +fun withViewOnScreen( + height: Int = MATCH_PARENT, + createView: (Context) -> V, + block: (V) -> Unit, +) { + Robolectric.registerTestActivity() + ActivityScenario.launch(EmptyAnkiActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val view = createView(activity) + activity.setContentView(view, LayoutParams(MATCH_PARENT, height)) + advanceRobolectricLooper() // lay out + block(view) + } + } +} + +/** Dispatches a touch [action], such as [MotionEvent.ACTION_UP], at the view's origin */ +fun View.dispatchTouch(action: Int) { + dispatchTouchEvent(obtainMotionEvent(downTime = 0, eventTime = 0, action = action, x = 0f, y = 0f, metaState = 0)) +} From 6f20b5bfba07c5d25cf32a70301be2aa1277a9ab Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:36:10 +0100 Subject: [PATCH 22/54] fix(card-browser): crash when scrolling after dropping a column If a dropped column is scrolled off-screen before the animation completes, `ItemTouchHelper` called `clearView`. Which threw an IllegalStateException. Defer the refresh while the RecyclerView is computing a layout or scrolling. Reproduced by regression test only, against a machine-generated report. Fixes 20557 Assisted-by: Claude Fable 5.1 --- .../anki/browser/BrowserColumnSelectionAdapter.kt | 3 ++- .../java/com/ichi2/anki/utils/ext/RecyclerView.kt | 13 +++++++++++++ ...rowserColumnSelectionTouchHelperCallbackTest.kt | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) 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 7eba16b5e343..162e2b097fbf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt @@ -14,6 +14,7 @@ import com.ichi2.anki.browser.BrowserColumnSelectionRecyclerItem.UsageItem import com.ichi2.anki.browser.ColumnUsage.AVAILABLE 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 class BrowserColumnSelectionAdapter( @@ -193,7 +194,7 @@ class BrowserColumnSelectionTouchHelperCallback( ) { // this needs to be done after onMoved, or the drag operation sometimes completes early // when on a tablet - recyclerView.adapter?.notifyItemRangeChanged(0, items.size) + recyclerView.runWhenNotComputingLayout { recyclerView.adapter?.notifyItemRangeChanged(0, items.size) } } override fun onSwiped( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/RecyclerView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/RecyclerView.kt index e499d23fab15..7c72b9a87c33 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/RecyclerView.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/RecyclerView.kt @@ -30,3 +30,16 @@ inline fun RecyclerView.doOnScrolled(crossinline action: (dx: Int, dy: Int) -> U }, ) } + +/** + * Runs [action] immediately, unless the RecyclerView is computing a layout or scrolling, in + * which case it is posted to the next frame. + * + * Callbacks such as `ItemTouchHelper.Callback.clearView` may be invoked during a layout, when the + * adapter may not be notified of changes. + * + * @see RecyclerView.isComputingLayout + */ +fun RecyclerView.runWhenNotComputingLayout(action: Runnable) { + if (isComputingLayout) post(action) else action.run() +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnSelectionTouchHelperCallbackTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnSelectionTouchHelperCallbackTest.kt index 68a1f8f6ae15..7b76ffeef7ad 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnSelectionTouchHelperCallbackTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnSelectionTouchHelperCallbackTest.kt @@ -19,6 +19,7 @@ import com.ichi2.testutils.changedPositions import com.ichi2.testutils.completeDroppedItem import com.ichi2.testutils.dispatchTouch import com.ichi2.testutils.dropDraggedItem +import com.ichi2.testutils.scrollToEnd import com.ichi2.testutils.withViewOnScreen import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.empty @@ -42,6 +43,19 @@ class BrowserColumnSelectionTouchHelperCallbackTest : RobolectricTest() { assertThat("refreshed after drop completes", changedRows, equalTo(list.allRows)) } + @Test + fun `scrolling a dropped row off-screen before it completes does not crash`() = + withColumnSelectionList { list -> + val changedRows = list.adapter!!.changedPositions() + list.startDrag(row = 1) + list.dropDraggedItem() + + // recycling the row cancels its animation, which calls clearView mid-scroll + list.scrollToEnd() + + assertThat("refreshed after scrolling", changedRows, equalTo(list.allRows)) + } + /** Displays the 'manage columns' list in a window a few rows tall, so a scroll recycles rows */ private fun withColumnSelectionList(block: (RecyclerView) -> Unit) = withViewOnScreen(height = LIST_HEIGHT_PX, createView = ::columnSelectionList) { list -> From e4ad3c29390a878063db15af7fa7df39cda6b885 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:28:48 +0100 Subject: [PATCH 23/54] fix(card-browser): call super.clearView() Fixes elevation of a dropped column. Assisted-by: Claude Fable 5.1 --- .../com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt | 2 ++ 1 file changed, 2 insertions(+) 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 162e2b097fbf..6dc3676e2dd3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt @@ -192,6 +192,8 @@ class BrowserColumnSelectionTouchHelperCallback( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) { + super.clearView(recyclerView, viewHolder) + // this needs to be done after onMoved, or the drag operation sometimes completes early // when on a tablet recyclerView.runWhenNotComputingLayout { recyclerView.adapter?.notifyItemRangeChanged(0, items.size) } From a9348c5b09f26abdbbaa3ba167b82d5c59862e27 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:24:37 +0100 Subject: [PATCH 24/54] fix(preferences): context menu titles use sentence case Part of 15760 Assisted-by: GPT-6 - implementation and validation --- AnkiDroid/src/main/res/values/10-preferences.xml | 2 +- AnkiDroid/src/main/res/xml/preferences_general.xml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index 85afe3ed606f..6b0b79bae373 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -97,7 +97,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/xml/preferences_general.xml b/AnkiDroid/src/main/res/xml/preferences_general.xml index 64d68b20e0e4..4338612cb169 100644 --- a/AnkiDroid/src/main/res/xml/preferences_general.xml +++ b/AnkiDroid/src/main/res/xml/preferences_general.xml @@ -76,13 +76,13 @@ android:id="@+id/anki_card_external_context_menu" android:defaultValue="true" android:key="@string/anki_card_external_context_menu_key" - tools:title="‘Anki Card’ Menu" + tools:title="‘Anki Card’ menu" tools:summary="Enables the ‘Anki Card’ context menu globally"/> \ No newline at end of file From 260cbaeffbaa72e557df780314df1f9fe8758df6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:44:16 +0100 Subject: [PATCH 25/54] fix: use sentence case for context menu labels Part of 15760 Assisted-by: GPT-6 - implementation and validation Assisted-by: Claude Fable 5.1 - test --- AnkiDroid/src/main/res/values/01-core.xml | 4 ++-- AnkiDroid/src/main/res/xml/preferences_general.xml | 8 ++++---- AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AnkiDroid/src/main/res/values/01-core.xml b/AnkiDroid/src/main/res/values/01-core.xml index 3f21d20a5157..a7a463915944 100644 --- a/AnkiDroid/src/main/res/values/01-core.xml +++ b/AnkiDroid/src/main/res/values/01-core.xml @@ -125,9 +125,9 @@ access existing notes, cards, note types, and decks, as well as create new ones - Card Browser + Card browser - Anki Card + Anki card The collections can’t be combined.\nWhich collection do you want to keep? diff --git a/AnkiDroid/src/main/res/xml/preferences_general.xml b/AnkiDroid/src/main/res/xml/preferences_general.xml index 4338612cb169..b1f2c4b3e75c 100644 --- a/AnkiDroid/src/main/res/xml/preferences_general.xml +++ b/AnkiDroid/src/main/res/xml/preferences_general.xml @@ -76,13 +76,13 @@ android:id="@+id/anki_card_external_context_menu" android:defaultValue="true" android:key="@string/anki_card_external_context_menu_key" - tools:title="‘Anki Card’ menu" - tools:summary="Enables the ‘Anki Card’ context menu globally"/> + tools:title="‘Anki card’ menu" + tools:summary="Enables the ‘Anki card’ context menu globally"/> + tools:title="‘Card browser’ menu" + tools:summary="Enables the ‘Card browser’ context menu globally"/> \ No newline at end of file diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index b29f86bc0c99..e8d4d6cfa3fb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -1110,7 +1110,7 @@ class DeckPickerTest : RobolectricTest() { equalTo( mapOf( "Alt+1" to "Deck picker", - "Alt+2" to "Card Browser", + "Alt+2" to "Card browser", "Alt+3" to "Open statistics", "Alt+4" to "More", ), From ad32499e91a88ed0fc71ef9f80be2131085b7fc5 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:03:39 +0100 Subject: [PATCH 26/54] fix(card-browser): keep saved selection during restore Read the saved selection when the ViewModel is created, and save it in place of the rows until a search applies it. Otherwise, saving state in initialization clobbered the previous value. Part of 19572 Assisted-by: Claude Fable 5.1 - all --- .../anki/browser/CardBrowserViewModel.kt | 67 ++++++++++--------- .../anki/browser/CardBrowserViewModelTest.kt | 41 ++++++++++++ 2 files changed, 75 insertions(+), 33 deletions(-) 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 ad0ffd136ee0..600c7f8d2d5f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -475,9 +475,12 @@ class CardBrowserViewModel( val flowOfInitCompleted = MutableStateFlow(false) /** - * Cards/notes to be selected on the next search after init completes. + * Cards/notes to select when the next search completes, restored from [STATE_MULTISELECT_VALUES]. + * + * While non-empty, the rows do not yet reflect the saved selection, so + * [generateExpensiveSavedState] saves this instead of [selectedRows] */ - private var pendingSelectionRestore: List = emptyList() + private var pendingSelectionRestore: List val flowOfColumnHeadings: StateFlow> = combine(flowOfActiveColumns, flowOfCardsOrNotes, flowOfAllColumns) { activeColumns, cardsOrNotes, allColumns -> @@ -523,6 +526,26 @@ class CardBrowserViewModel( init { Timber.d("CardBrowserViewModel::init, launchOptions: '${options?.javaClass?.simpleName}'") + // restore selection state + val idsFile = + savedStateHandle.get(STATE_MULTISELECT_VALUES)?.let { bundle -> + BundleCompat.getParcelable(bundle, STATE_MULTISELECT_VALUES, IdsFile::class.java) + } + pendingSelectionRestore = + try { + idsFile?.getIds()?.map { CardOrNoteId(it) } + } catch (e: Exception) { + // #19572: I suspect we have a startup bug here, so continue reporting the exception + Timber.w(e, "failed to read STATE_MULTISELECT_VALUES") + CrashReportService.sendExceptionReport( + e = e, + origin = "19572: STATE_MULTISELECT_VALUES", + onlyIfSilent = true, + ) + // fallback to no selections, but still in multiselect mode + null + } ?: emptyList() + var selectAllDecks = false when (options) { is CardBrowserLaunchOptions.SystemContextMenu -> { @@ -544,8 +567,7 @@ class CardBrowserViewModel( performSearchFlow .onEach { Timber.d("performSearchFlow -> launching search") - val ids = pendingSelectionRestore.also { pendingSelectionRestore = emptyList() } - launchSearchForCards(cardOrNoteIdsToSelect = ids) + launchSearchForCards() }.launchIn(viewModelScope) flowOfCardsOrNotes @@ -567,26 +589,6 @@ class CardBrowserViewModel( Timber.i("initCompleted") if (!manualInit) { - // restore selection state - val idsFile = - savedStateHandle.get(STATE_MULTISELECT_VALUES)?.let { bundle -> - BundleCompat.getParcelable(bundle, STATE_MULTISELECT_VALUES, IdsFile::class.java) - } - pendingSelectionRestore = - try { - idsFile?.getIds()?.map { CardOrNoteId(it) } - } catch (e: Exception) { - // #19572: I suspect we have a startup bug here, so continue reporting the exception - Timber.w(e, "failed to read STATE_MULTISELECT_VALUES") - CrashReportService.sendExceptionReport( - e = e, - origin = "19572: STATE_MULTISELECT_VALUES", - onlyIfSilent = true, - ) - // fallback to no selections, but still in multiselect mode - null - } ?: emptyList() - flowOfInitCompleted.update { true } } } @@ -601,8 +603,10 @@ class CardBrowserViewModel( @VisibleForTesting // far too complicated to mock setSavedStateProvider fun generateExpensiveSavedState() = Bundle().apply { - if (selectedRows.isEmpty()) return@apply - putParcelable(STATE_MULTISELECT_VALUES, IdsFile(cacheDir, selectedRows.map { it.cardOrNoteId }, "multiselect-values")) + // a restored selection not yet applied to the rows is still the selection to save + val selection = pendingSelectionRestore.ifEmpty { selectedRows.toList() } + if (selection.isEmpty()) return@apply + putParcelable(STATE_MULTISELECT_VALUES, IdsFile(cacheDir, selection.map { it.cardOrNoteId }, "multiselect-values")) } /** @@ -1361,16 +1365,12 @@ class CardBrowserViewModel( } /** - * @param cardOrNoteIdsToSelect if the screen is reinitialized after destruction - * restore these rows after the search is completed + * Once the search completes, [pendingSelectionRestore] is applied to the rows * * @see com.ichi2.anki.searchForRows */ @NeedsTest("Invalid searches are handled. For instance: 'and'") - fun launchSearchForCards( - cardOrNoteIdsToSelect: List = emptyList(), - fromUserSearch: Boolean = false, - ) { + fun launchSearchForCards(fromUserSearch: Boolean = false) { if (!initCompleted) return viewModelScope.launch { @@ -1394,7 +1394,8 @@ class CardBrowserViewModel( ensurePaneRowValid() if (isFragmented) flowOfNoteEditorCommand.emit(NoteEditorCommand.fromCurrentSearchState()) flowOfSearchState.emit(SearchState.Completed.fromCurrentState(fromUserSearch)) - selectUnvalidatedRowIds(cardOrNoteIdsToSelect) + selectUnvalidatedRowIds(pendingSelectionRestore) + pendingSelectionRestore = emptyList() } viewModelScope.launch { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt index 1fe295161127..1dab10f8727c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -1691,6 +1691,47 @@ class CardBrowserViewModelTest : JvmTest() { } } + @Test + fun `multiselect state is kept if saved before the selection is restored`() { + val handle = SavedStateHandle() + runViewModelTest(savedStateHandle = handle, notes = 2, initMode = InitMode.NO_DELAY) { + selectRowAtPosition(1) + // HACK: easiest way to add it to the bundle. This is called on destruction + handle[STATE_MULTISELECT_VALUES] = generateExpensiveSavedState() + } + + // MANUAL: the restored selection has not been applied to the rows when state is saved + runViewModelTest(savedStateHandle = handle, initMode = InitMode.MANUAL) { + assertThat("rows are not yet selected", selectedRows, empty()) + handle[STATE_MULTISELECT_VALUES] = generateExpensiveSavedState() + } + + runViewModelTest(savedStateHandle = handle, initMode = InitMode.NO_DELAY) { + assertThat("row is selected after restore", selectedRows, hasSize(1)) + } + } + + @Test + fun `saved state reflects a selection changed after restore`() { + val handle = SavedStateHandle() + runViewModelTest(savedStateHandle = handle, notes = 2, initMode = InitMode.NO_DELAY) { + selectRowAtPosition(1) + // HACK: easiest way to add it to the bundle. This is called on destruction + handle[STATE_MULTISELECT_VALUES] = generateExpensiveSavedState() + } + + runViewModelTest(savedStateHandle = handle, initMode = InitMode.NO_DELAY) { + assertThat("row is selected after restore", selectedRows, hasSize(1)) + selectRowAtPosition(0) + assertThat("two rows are selected", selectedRows, hasSize(2)) + + handle[STATE_MULTISELECT_VALUES] = generateExpensiveSavedState() + val savedIds = handle.multiselectStateFile!!.getIds() + val expectedIds = selectedRows.map { it.cardOrNoteId } + assertThat("saved selection matches the rows", savedIds, containsInAnyOrder(*expectedIds.toTypedArray())) + } + } + @Test fun `change note type - no selection`() = runViewModelTest { From ae23786e86e918cf03de3258ca2c6dcb26895307 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:17:18 +0100 Subject: [PATCH 27/54] feat(collection): add Brief/Exclusive queue Previously, a sync had the same priority as getting the deck counts. Add: * `withColExclusive` publish a `CollectionLease` * `tryWithCol` does not execute if in the 'Exclusive' state via cancellation. No-op as nothing uses `tryWithCol` yet Part of 17551 Assisted-by: Claude Opus 5 - original code Assisted-by: GPT-6 - lifecycle & regression tests --- .../src/main/java/com/ichi2/anki/Sync.kt | 16 +- .../com/ichi2/anki/CollectionLeaseTest.kt | 283 ++++++++++++++++++ .../kotlin/com/ichi2/anki/CollectionLease.kt | 110 +++++++ .../com/ichi2/anki/CollectionLeaseManager.kt | 73 +++++ .../com/ichi2/anki/CollectionManager.kt | 83 ++++- 5 files changed, 558 insertions(+), 7 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt create mode 100644 anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt create mode 100644 anki-common/src/main/kotlin/com/ichi2/anki/CollectionLeaseManager.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt index 0282ac64d5b3..1348196e5eb5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt @@ -12,6 +12,7 @@ import anki.sync.syncAuth import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.CollectionManager.withColExclusive import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.dialogs.SyncErrorDialog import com.ichi2.anki.observability.ChangeManager.notifySubscribersAllValuesChanged @@ -145,7 +146,10 @@ private suspend fun handleNormalSync( onCancel = ::cancelSync, manualCancelButton = R.string.dialog_cancel, ) { - withCol { + withColExclusive( + operation = CollectionOperation.SYNC, + onCancel = ::cancelSync, + ) { syncCollection(auth2, syncMedia = false) // media is synced by SyncMediaWorker } } @@ -224,7 +228,10 @@ private suspend fun handleDownload( onCancel = ::cancelSync, manualCancelButton = R.string.dialog_cancel, ) { - withCol { + withColExclusive( + operation = CollectionOperation.FULL_DOWNLOAD, + onCancel = ::cancelSync, + ) { try { createBackup( BackupManager.getBackupDirectoryFromCollection(colDb), @@ -259,7 +266,10 @@ private suspend fun handleUpload( onCancel = ::cancelSync, manualCancelButton = R.string.dialog_cancel, ) { - withCol { + withColExclusive( + operation = CollectionOperation.FULL_UPLOAD, + onCancel = ::cancelSync, + ) { close(downgrade = false, forFullSync = true) try { fullUploadOrDownload(auth, upload = true, serverUsn = mediaUsn) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt new file mode 100644 index 000000000000..dd2a85cd0555 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager.collectionLease +import com.ichi2.anki.CollectionManager.tryWithCol +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.CollectionManager.withColExclusive +import com.ichi2.anki.CollectionManager.withLeaseForTest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import net.ankiweb.rsdroid.Backend +import net.ankiweb.rsdroid.BackendException +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Test of [CollectionLease], and of the callers which react to one being held */ +@RunWith(AndroidJUnit4::class) +class CollectionLeaseTest : RobolectricTest() { + @Test + fun `no lease is held by default`() = + runTest { + assertThat(collectionLease, nullValue()) + } + + @Test + fun `a brief operation takes no lease`() = + runTest { + withCol { + assertThat(collectionLease, nullValue()) + } + } + + @Test + fun `lease is held for the duration of the operation`() = + runTest { + withColExclusive(CollectionOperation.SYNC) { + assertThat("held inside the block", collectionLease, notNullValue()) + assertThat(collectionLease!!.operation, equalTo(CollectionOperation.SYNC)) + } + + assertThat("released after the block", collectionLease, nullValue()) + } + + @Test + fun `lease is released if the operation throws`() = + runTest { + assertFailsWith { + withColExclusive(CollectionOperation.FULL_DOWNLOAD) { + error("sync failed") + } + } + + assertThat(collectionLease, nullValue()) + } + + @Test + fun `cancel is exposed to observers of the lease`() = + runTest { + var abortCalls = 0 + + withColExclusive(CollectionOperation.SYNC, onCancel = { abortCalls++ }) { + // an observer cancels the operation without needing to know what it is + collectionLease!!.cancel() + collectionLease!!.cancel() + } + + assertEquals(1, abortCalls, "onCancel is invoked only once via the lease") + } + + @Test + fun `onCancel is handed the backend the operation is running against`() = + runTest { + // a handler which resolved its own backend would have to enter the collection queue, + // which the operation being cancelled is holding + var cancelledWith: Backend? = null + + withColExclusive(CollectionOperation.SYNC, onCancel = { cancelledWith = it }) { + collectionLease!!.cancel() + } + + assertSame(CollectionManager.getBackend(), cancelledWith, "onCancel receives the live backend") + } + + @Test + fun `tryWithCol runs the block when no lease is held`() = + runTest { + assertThat(tryWithCol { decks.count() }, equalTo(1)) + } + + @Test + fun `tryWithCol returns null rather than queueing behind a lease`() = + runTest { + val result = + withLeaseForTest(CollectionOperation.SYNC) { + tryWithCol { decks.count() } + } + + assertThat("skipped rather than queued", result, nullValue()) + } + + @Test + fun `tryWithCol skips for any long-running operation, not just sync`() = + runTest { + val result = + withLeaseForTest(CollectionOperation.CHECK_DATABASE) { + tryWithCol { decks.count() } + } + + assertThat(result, nullValue()) + } + + @Test + fun `tryWithCol resumes once the lease is released`() = + runTest { + withLeaseForTest(CollectionOperation.SYNC) { + assertThat(tryWithCol { decks.count() }, nullValue()) + } + + assertThat("no longer skipped", tryWithCol { decks.count() }, equalTo(1)) + } + + @Test + fun `cancelling an overlapping operation preserves the running lease`() = + runTest { + withProductionQueue { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val first = + launch(start = CoroutineStart.UNDISPATCHED) { + withColExclusive(CollectionOperation.SYNC) { + entered.countDown() + assertTrue(release.await(10, TimeUnit.SECONDS), "test did not release sync") + } + } + try { + assertTrue(entered.await(5, TimeUnit.SECONDS), "sync did not enter the queue") + val lease = collectionLease!! + val second = + launch(start = CoroutineStart.UNDISPATCHED) { + withColExclusive(CollectionOperation.FULL_DOWNLOAD) { + error("cancelled operation must not run") + } + } + assertSame(lease, collectionLease, "a waiting operation must not replace the owner") + second.cancelAndJoin() + assertSame(lease, collectionLease, "cancelling a waiter must not clear the owner") + assertThat(tryWithCol { error("sync still holds the queue") }, nullValue()) + } finally { + release.countDown() + } + first.join() + assertThat(collectionLease, nullValue()) + } + } + + @Test + fun `cancelling a queued lease skips its block without aborting the running operation`() = + runTest { + withProductionQueue { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + launch(start = CoroutineStart.UNDISPATCHED) { + withCol { + entered.countDown() + assertTrue(release.await(10, TimeUnit.SECONDS), "test did not release collection") + } + } + var abortCalls = 0 + var blockRan = false + try { + assertTrue(entered.await(5, TimeUnit.SECONDS), "brief operation did not enter the queue") + val queued = + launch(start = CoroutineStart.UNDISPATCHED) { + withColExclusive(CollectionOperation.SYNC, onCancel = { abortCalls++ }) { + blockRan = true + } + } + collectionLease!!.cancel() + release.countDown() + queued.join() + assertTrue(queued.isCancelled, "Cancel must cancel the queued coroutine") + assertFalse(blockRan, "a cancelled lease must not enter its block") + assertEquals(0, abortCalls, "backend cancellation belongs only to a running lease") + assertThat(collectionLease, nullValue()) + } finally { + release.countDown() + } + } + } + + @Test + fun `a completed lease cannot cancel a subsequent operation`() = + runTest { + var abortCalls = 0 + val oldLease = + withColExclusive(CollectionOperation.SYNC, onCancel = { abortCalls++ }) { + collectionLease!! + } + withColExclusive(CollectionOperation.FULL_DOWNLOAD) { + oldLease.cancel() + assertEquals(0, abortCalls, "a retained Cancel handler must be inert after completion") + } + } + + @Test + fun `overlapping operations acquire distinct leases in order`() = + runTest { + withProductionQueue { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val first = + async(start = CoroutineStart.UNDISPATCHED) { + withColExclusive(CollectionOperation.SYNC) { + entered.countDown() + assertTrue(release.await(10, TimeUnit.SECONDS), "test did not release sync") + collectionLease!! + } + } + val second = + try { + assertTrue(entered.await(5, TimeUnit.SECONDS), "sync did not enter the queue") + val lease = collectionLease!! + async(start = CoroutineStart.UNDISPATCHED) { + withColExclusive(CollectionOperation.FULL_DOWNLOAD) { collectionLease!! } + }.also { + assertSame(lease, collectionLease, "sync retains ownership until completion") + } + } finally { + release.countDown() + } + assertEquals(CollectionOperation.SYNC, first.await().operation) + assertEquals(CollectionOperation.FULL_DOWNLOAD, second.await().operation) + assertThat(collectionLease, nullValue()) + } + } + + @Test + fun `failure to open the collection releases lease ownership`() = + runTest { + CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED + try { + assertFailsWith { + withColExclusive(CollectionOperation.SYNC) { error("collection could not open") } + } + assertThat(collectionLease, nullValue()) + } finally { + CollectionManager.emulatedOpenFailure = null + } + withColExclusive(CollectionOperation.FULL_DOWNLOAD) { + assertEquals(CollectionOperation.FULL_DOWNLOAD, collectionLease!!.operation) + } + } + + /** Exercise the real withContext queue, and restore Robolectric's lock before teardown. */ + private suspend fun TestScope.withProductionQueue(block: suspend CoroutineScope.() -> Unit) { + CollectionManager.setTestDispatcher(Dispatchers.IO.limitedParallelism(1), useReentrantLock = false) + try { + coroutineScope(block) + } finally { + CollectionManager.setTestDispatcher(UnconfinedTestDispatcher(testScheduler)) + } + } +} diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt new file mode 100644 index 000000000000..081060469f59 --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import com.ichi2.anki.common.time.TimeManager +import com.ichi2.anki.libanki.EpochMilliseconds +import kotlinx.coroutines.CancellationException +import net.ankiweb.rsdroid.Backend + +/** + * A collection operation which blocks the collection for a long period of time (Sync, Import, etc...). + * + * NOTE: This is defined by duration; all collection operations block. + * + * @see CollectionManager.withColExclusive + */ +enum class CollectionOperation { + SYNC, + FULL_DOWNLOAD, + FULL_UPLOAD, + IMPORT_COLLECTION_PACKAGE, + CHECK_DATABASE, + CHECK_MEDIA, + EMPTY_CARDS, +} + +/** + * An in-flight [CollectionOperation]: either running on the collection queue, or waiting to enter + * it, acting as an exclusive lock. + * + * @param operation what is holding the queue + * @param cancelQueued cancels the coroutine waiting to enter the collection queue + * @param cancelRunning requests cancellation of the running operation without waiting for it to end + */ +class CollectionLease internal constructor( + val operation: CollectionOperation, + val startedAt: EpochMilliseconds, + private val cancelQueued: () -> Unit, + private val cancelRunning: (Backend) -> Unit, +) { + private sealed class State { + data object Queued : State() + + /** @param backend the collection was opened against, for [cancelRunning] */ + data class Running( + val backend: Backend, + ) : State() + + data object Cancelled : State() + + data object Finished : State() + } + + private val lock = Any() + private var state: State = State.Queued + + /** + * Skip a queued operation, or request cancellation of a running one. A running operation may + * not stop immediately, or at all. Repeated requests and requests after completion do nothing. + */ + val cancel: () -> Unit = { + synchronized(lock) { + // Keep the callback under the lock: it must finish before another operation starts + // using the backend, otherwise an old Cancel button could abort that operation. + when (val current = state) { + State.Queued -> { + state = State.Cancelled + cancelQueued() + } + is State.Running -> { + state = State.Cancelled + cancelRunning(current.backend) + } + State.Cancelled, State.Finished -> {} + } + } + } + + /** + * Called inside the collection queue, after opening the collection. + * + * @param backend handed to [cancelRunning] if the operation is cancelled while it runs + */ + internal fun run( + backend: Backend, + block: () -> T, + ): T { + synchronized(lock) { + if (state != State.Queued) throw CancellationException("Collection lease cancelled before execution") + state = State.Running(backend) + } + try { + return block() + } finally { + // Disable cancellation before leaving the queue, not when the caller resumes later. + finish() + } + } + + internal fun finish() { + synchronized(lock) { + state = State.Finished + } + } + + /** Milliseconds elapsed since the operation started. Useful when diagnosing a blocked caller. */ + fun elapsedMs(): Long = TimeManager.time.intTimeMS() - startedAt + + override fun toString(): String = "CollectionLease($operation, held for ${elapsedMs()}ms)" +} diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLeaseManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLeaseManager.kt new file mode 100644 index 000000000000..086e31145029 --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLeaseManager.kt @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import com.ichi2.anki.common.time.TimeManager +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.ankiweb.rsdroid.Backend +import timber.log.Timber + +/** + * Owns [collection lease][CollectionLease] acquisition, publication and cleanup. + * + * @see withLease + */ +internal class CollectionLeaseManager { + // Serialize ownership before entering the collection queue, so a waiter cannot replace its owner. + private val mutex = Mutex() + + val flowOfLease: StateFlow + field = MutableStateFlow(null) + + val lease: CollectionLease? get() = flowOfLease.value + + /** + * Publishes one lease at a time, cancelling queued work through its own coroutine scope. + * + * If a lease is already active, suspend until the lease is released. + */ + suspend fun withLease( + operation: CollectionOperation, + onCancel: (Backend) -> Unit = {}, + block: suspend (CollectionLease) -> T, + ): T = + mutex.withLock { + coroutineScope { + val lease = + CollectionLease( + operation = operation, + startedAt = TimeManager.time.intTimeMS(), + cancelQueued = { cancel() }, + cancelRunning = onCancel, + ) + flowOfLease.value = lease + Timber.d("collection lease acquired: %s", operation) + try { + block(lease) + } finally { + lease.finish() + flowOfLease.value = null + Timber.d("collection lease released: %s", lease) + } + } + } + + /** + * Skips disposable work when a lease is held. + * + * Note: due to interleaving, a lease acquired after the check may still cause this call + * to wait for the collection queue. + */ + suspend fun tryWithoutLease(block: suspend () -> T): T? { + lease?.let { + Timber.d("tryWithCol: skipped, %s", it) + return null + } + return block() + } +} diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt index 4b577d6e6d11..6e46d1e0cc44 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt @@ -35,6 +35,7 @@ import com.ichi2.anki.libanki.Storage.collection import com.ichi2.anki.libanki.importCollectionPackage import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.Backend @@ -76,6 +77,8 @@ object CollectionManager { private var queue: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + private val leases = CollectionLeaseManager() + /** * Test-only: emulates a number of failure cases when opening the collection * @@ -86,6 +89,8 @@ object CollectionManager { private val testMutex = ReentrantLock() + private var useTestMutex = true + private var currentSyncCertificate: String = "" /** @@ -113,7 +118,7 @@ object CollectionManager { private suspend fun withQueue( @WorkerThread block: CollectionManager.() -> T, ): T { - if (isRobolectric) { + if (isRobolectric && useTestMutex) { // #16253 Robolectric Windows: `withContext(queue)` is insufficient for serial execution return testMutex.withLock { this@CollectionManager.block() @@ -146,6 +151,68 @@ object CollectionManager { block(collection!!) } + /** + * The in-flight long-running collection operation, if any. + * + * Observe this to: + * * React to the collection becoming unavailable for an extended period + * * Skip work which is not worth queueing behind a long operation + * * Offer cancellation via [CollectionLease.cancel] + * * Show progress for the operation + * + * @see withColExclusive + * @see tryWithCol + */ + val flowOfCollectionLease: StateFlow = leases.flowOfLease + + /** @see flowOfCollectionLease */ + val collectionLease: CollectionLease? get() = leases.lease + + /** + * [withCol], publishing a [CollectionLease] on [flowOfCollectionLease] for the duration. + * + * @param onCancel cancels [operation], must return promptly + */ + suspend fun withColExclusive( + operation: CollectionOperation, + onCancel: (Backend) -> Unit = {}, + @WorkerThread block: Collection.() -> T, + ): T = + leases.withLease(operation, onCancel) { lease -> + withQueue { + ensureOpenInner() + // non-null after ensureOpenInner: passed on so `onCancel` never enters the queue + lease.run(backend!!) { block(collection!!) } + } + } + + /** Holds a lease for [operation] without occupying the queue, which a real operation would. */ + @VisibleForTesting + suspend fun withLeaseForTest( + operation: CollectionOperation, + block: suspend () -> T, + ): T = + leases.withLease(operation) { + block() + } + + /** + * Use this for quick, disposable work. [block] is not executed and `null` is returned if + * [CollectionLease] is held. + * + * In normal cases this is equivalent to [withCol]. + * + * Example usage: obtaining deck counts while a sync may be ongoing. + * + * Note: A lease acquired after the check can still make this call wait behind the operation. + */ + suspend fun tryWithCol( + @WorkerThread block: Collection.() -> T, + ): T? = + leases.tryWithoutLease { + withCol(block) + } + /** * Execute the provided block if the collection is already open. See [withCol] for more. * Since the block may return a null value, and a null value will also be returned in the @@ -335,7 +402,7 @@ object CollectionManager { * under Robolectric, this uses a mutex */ private fun blockForQueue(block: CollectionManager.() -> T): T = - if (isRobolectric) { + if (isRobolectric && useTestMutex) { testMutex.withLock { block(this) } @@ -445,12 +512,20 @@ object CollectionManager { } } - /** @return the previous dispatcher, so a test can restore it */ - fun setTestDispatcher(dispatcher: CoroutineDispatcher): CoroutineDispatcher { + /** + * Set [useReentrantLock] to false to exercise the dispatcher queue in concurrency tests. + * + * @return the previous dispatcher, so a test can restore it + */ + fun setTestDispatcher( + dispatcher: CoroutineDispatcher, + useReentrantLock: Boolean = true, + ): CoroutineDispatcher { // note: we avoid the call to .limitedParallelism() here, // as it does not seem to be compatible with the test scheduler val previous = queue queue = dispatcher + useTestMutex = useReentrantLock return previous } From 5c7008270a58424bffa19b8a24de2a0a652d46e8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:56:24 +0100 Subject: [PATCH 28/54] fix(collection): make `tryWithCol` atomic `tryWithCol` could interleave with a lease, causing the operation to wait * A lease now waits for work to complete before executing in Exclusive mode Part of 17551 Assisted-by: GPT-6 --- .../com/ichi2/anki/CollectionLeaseTest.kt | 171 +++++++++++++++++- .../com/ichi2/anki/CollectionLeaseManager.kt | 58 +++++- .../com/ichi2/anki/CollectionManager.kt | 7 +- 3 files changed, 217 insertions(+), 19 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt index dd2a85cd0555..99af3bf29880 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt @@ -8,6 +8,7 @@ import com.ichi2.anki.CollectionManager.tryWithCol import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CollectionManager.withColExclusive import com.ichi2.anki.CollectionManager.withLeaseForTest +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers @@ -15,6 +16,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import net.ankiweb.rsdroid.Backend @@ -27,6 +29,7 @@ import org.junit.Test import org.junit.runner.RunWith import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -139,10 +142,163 @@ class CollectionLeaseTest : RobolectricTest() { assertThat("no longer skipped", tryWithCol { decks.count() }, equalTo(1)) } + @Test + fun `a lease cannot overtake brief work paused before queue dispatch`() = + runTest { + val queue = StandardTestDispatcher(testScheduler) + var dispatchBrief: (() -> Unit)? = null + val pausedQueue = + object : CoroutineDispatcher() { + override fun dispatch( + context: CoroutineContext, + block: Runnable, + ) { + if (dispatchBrief == null) { + // Pause after admission but before submitting to the serial queue. + dispatchBrief = { queue.dispatch(context, block) } + } else { + queue.dispatch(context, block) + } + } + } + withCollectionQueue(pausedQueue) { + val events = mutableListOf() + val brief = + async(start = CoroutineStart.UNDISPATCHED) { + tryWithCol { events.add("brief") } + } + val otherBrief = + async(start = CoroutineStart.UNDISPATCHED) { + tryWithCol { events.add("other brief") } + } + val sync = + launch(start = CoroutineStart.UNDISPATCHED) { + withColExclusive(CollectionOperation.SYNC) { events.add("sync") } + } + try { + testScheduler.runCurrent() + assertEquals(true, otherBrief.await(), "overlapping brief work must not be skipped") + assertEquals(listOf("other brief"), events, "sync must wait for every admitted brief call") + assertThat(collectionLease, notNullValue()) + assertThat(tryWithCol { error("new work must be skipped while sync waits") }, nullValue()) + } finally { + checkNotNull(dispatchBrief).invoke() + } + assertEquals(true, brief.await()) + sync.join() + assertEquals(listOf("other brief", "brief", "sync"), events) + } + } + + @Test + fun `a lease waits for all admitted brief calls without skipping overlapping calls`() = + runTest { + withCollectionQueue(StandardTestDispatcher(testScheduler)) { + val events = mutableListOf() + val first = + async(start = CoroutineStart.UNDISPATCHED) { + tryWithCol { events.add("first") } + } + val second = + async(start = CoroutineStart.UNDISPATCHED) { + tryWithCol { events.add("second") } + } + val sync = + launch(start = CoroutineStart.UNDISPATCHED) { + // Observe admission independently of the collection queue's ordering. + withLeaseForTest(CollectionOperation.SYNC) { events.add("sync") } + } + assertFalse(sync.isCompleted, "the lease must wait for both admitted calls") + assertThat(collectionLease, notNullValue()) + assertThat(tryWithCol { error("new work must be skipped while sync waits") }, nullValue()) + assertEquals(true, first.await()) + assertEquals(true, second.await()) + sync.join() + assertEquals(listOf("first", "second", "sync"), events) + assertThat(collectionLease, nullValue()) + } + } + + @Test + fun `cancelling admitted brief work allows a waiting lease to proceed`() = + runTest { + withCollectionQueue(StandardTestDispatcher(testScheduler)) { + val brief = + launch(start = CoroutineStart.UNDISPATCHED) { + tryWithCol { error("cancelled brief work must not run") } + } + var syncRan = false + val sync = + launch(start = CoroutineStart.UNDISPATCHED) { + withLeaseForTest(CollectionOperation.SYNC) { syncRan = true } + } + assertFalse(syncRan) + brief.cancelAndJoin() + sync.join() + assertTrue(syncRan) + assertThat(collectionLease, nullValue()) + assertEquals(1, tryWithCol { decks.count() }) + } + } + + @Test + fun `failing admitted brief work allows a waiting lease to proceed`() = + runTest { + withCollectionQueue(StandardTestDispatcher(testScheduler)) { + val brief = + launch(start = CoroutineStart.UNDISPATCHED) { + assertFailsWith { + tryWithCol { error("brief work failed") } + } + } + var syncRan = false + val sync = + launch(start = CoroutineStart.UNDISPATCHED) { + withLeaseForTest(CollectionOperation.SYNC) { syncRan = true } + } + assertFalse(syncRan) + brief.join() + sync.join() + assertTrue(syncRan) + assertThat(collectionLease, nullValue()) + assertEquals(1, tryWithCol { decks.count() }) + } + } + + @Test + fun `cancelling a lease waiting for brief work allows more brief work`() = + runTest { + withCollectionQueue(StandardTestDispatcher(testScheduler)) { + val first = async(start = CoroutineStart.UNDISPATCHED) { tryWithCol { decks.count() } } + var abortCalls = 0 + val sync = + launch(start = CoroutineStart.UNDISPATCHED) { + withColExclusive(CollectionOperation.SYNC, onCancel = { abortCalls++ }) { + error("cancelled sync must not run") + } + } + collectionLease!!.cancel() + sync.join() + assertTrue(sync.isCancelled) + assertEquals(0, abortCalls) + assertThat(collectionLease, nullValue()) + val second = async(start = CoroutineStart.UNDISPATCHED) { tryWithCol { decks.count() } } + val next = + launch(start = CoroutineStart.UNDISPATCHED) { + withLeaseForTest(CollectionOperation.FULL_DOWNLOAD) { } + } + assertFalse(next.isCompleted, "the next lease must still wait for admitted work") + assertEquals(1, first.await()) + assertEquals(1, second.await()) + next.join() + assertThat(collectionLease, nullValue()) + } + } + @Test fun `cancelling an overlapping operation preserves the running lease`() = runTest { - withProductionQueue { + withCollectionQueue { val entered = CountDownLatch(1) val release = CountDownLatch(1) val first = @@ -176,7 +332,7 @@ class CollectionLeaseTest : RobolectricTest() { @Test fun `cancelling a queued lease skips its block without aborting the running operation`() = runTest { - withProductionQueue { + withCollectionQueue { val entered = CountDownLatch(1) val release = CountDownLatch(1) launch(start = CoroutineStart.UNDISPATCHED) { @@ -225,7 +381,7 @@ class CollectionLeaseTest : RobolectricTest() { @Test fun `overlapping operations acquire distinct leases in order`() = runTest { - withProductionQueue { + withCollectionQueue { val entered = CountDownLatch(1) val release = CountDownLatch(1) val first = @@ -271,9 +427,12 @@ class CollectionLeaseTest : RobolectricTest() { } } - /** Exercise the real withContext queue, and restore Robolectric's lock before teardown. */ - private suspend fun TestScope.withProductionQueue(block: suspend CoroutineScope.() -> Unit) { - CollectionManager.setTestDispatcher(Dispatchers.IO.limitedParallelism(1), useReentrantLock = false) + /** Exercise the withContext queue, and restore Robolectric's lock before teardown. */ + private suspend fun TestScope.withCollectionQueue( + dispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1), + block: suspend CoroutineScope.() -> Unit, + ) { + CollectionManager.setTestDispatcher(dispatcher, useReentrantLock = false) try { coroutineScope(block) } finally { diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLeaseManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLeaseManager.kt index 086e31145029..1d9b9120b6b1 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLeaseManager.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLeaseManager.kt @@ -3,6 +3,7 @@ package com.ichi2.anki import com.ichi2.anki.common.time.TimeManager +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +22,13 @@ internal class CollectionLeaseManager { // Serialize ownership before entering the collection queue, so a waiter cannot replace its owner. private val mutex = Mutex() + /** + * Used in both [tryWithoutLease] and [withLease], so a lease waits for all previously accepted calls. + */ + private val acceptanceLock = Any() + private var briefCallCount = 0 + private var briefCallsFinished: CompletableDeferred? = null + val flowOfLease: StateFlow field = MutableStateFlow(null) @@ -30,6 +38,10 @@ internal class CollectionLeaseManager { * Publishes one lease at a time, cancelling queued work through its own coroutine scope. * * If a lease is already active, suspend until the lease is released. + * + * No new brief calls can be accepted after the lease is published. + * + * All previously accepted calls must finish before invoking [block]. */ suspend fun withLease( operation: CollectionOperation, @@ -45,29 +57,57 @@ internal class CollectionLeaseManager { cancelQueued = { cancel() }, cancelRunning = onCancel, ) - flowOfLease.value = lease + // wait for brief jobs to finish (& acquire briefCallsFinished to await) + val briefWork = + synchronized(acceptanceLock) { + flowOfLease.value = lease + briefCallsFinished + } Timber.d("collection lease acquired: %s", operation) try { + // wait for all brief jobs to complete + briefWork?.await() block(lease) } finally { lease.finish() - flowOfLease.value = null + synchronized(acceptanceLock) { + flowOfLease.value = null + } Timber.d("collection lease released: %s", lease) } } } /** - * Skips disposable work when a lease is held. + * Skips disposable work when a [CollectionLease] is held. * - * Note: due to interleaving, a lease acquired after the check may still cause this call - * to wait for the collection queue. + * Otherwise, reserves access until [block] finishes. */ suspend fun tryWithoutLease(block: suspend () -> T): T? { - lease?.let { - Timber.d("tryWithCol: skipped, %s", it) - return null + // Overlapping brief calls are accepted together. + // `withLease` waits for all of them to complete before entering the collection queue. + synchronized(acceptanceLock) { + lease?.let { + Timber.d("tryWithCol: skipped, %s", it) + return null + } + if (briefCallCount++ == 0) { + briefCallsFinished = CompletableDeferred() + } + } + try { + return block() + } finally { + val finished = + synchronized(acceptanceLock) { + if (--briefCallCount == 0) { + briefCallsFinished.also { briefCallsFinished = null } + } else { + null + } + } + // Resume the waiting lease outside the lock. + finished?.complete(Unit) } - return block() } } diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt index 6e46d1e0cc44..3c5ecd404602 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt @@ -171,6 +171,9 @@ object CollectionManager { /** * [withCol], publishing a [CollectionLease] on [flowOfCollectionLease] for the duration. * + * Previously admitted [tryWithCol] calls finish before this operation enters the queue. + * New [tryWithCol] calls are skipped while this operation waits or runs. + * * @param onCancel cancels [operation], must return promptly */ suspend fun withColExclusive( @@ -200,11 +203,7 @@ object CollectionManager { * Use this for quick, disposable work. [block] is not executed and `null` is returned if * [CollectionLease] is held. * - * In normal cases this is equivalent to [withCol]. - * * Example usage: obtaining deck counts while a sync may be ongoing. - * - * Note: A lease acquired after the check can still make this call wait behind the operation. */ suspend fun tryWithCol( @WorkerThread block: Collection.() -> T, From 95ae9b99c46e06166bcfa13a83a2f6ec50590ebd Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:17:30 +0100 Subject: [PATCH 29/54] docs: add issue 17551 TODOs Part of 17551 Assisted-by: GPT-6 --- AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt | 2 ++ .../main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt | 2 +- anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt | 1 + anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index cb2e83ffa013..b9de1b56aeb4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -421,6 +421,8 @@ suspend fun Fragment.withProgress( block: suspend () -> T, ): T = requireActivity().withProgress(messageId, block) +// TODO: #17551 add a lease-owned progress UI, with `CollectionLease.cancel` as its Cancel handler. +// Rethink `progressDialogShown` and `FLAG_NOT_TOUCHABLE`. @Suppress("Deprecation") // ProgressDialog deprecation suspend fun withProgressDialog( context: Activity, 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 30373e86f07f..274f78dcabb4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -356,7 +356,7 @@ class DeckPickerViewModel : * * This method also triggers an update for the widget to reflect the newly calculated counts. */ - // TODO: this should not be executed if syncing - called after sync is completed + // 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) { diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt index 081060469f59..f0ec1ec4b7e1 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionLease.kt @@ -14,6 +14,7 @@ import net.ankiweb.rsdroid.Backend * * @see CollectionManager.withColExclusive */ +// TODO: #17551 Add leases for unused constants & profile the app to find more enum class CollectionOperation { SYNC, FULL_DOWNLOAD, diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt index 3c5ecd404602..cc80d0bfa770 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt @@ -513,6 +513,7 @@ object CollectionManager { /** * Set [useReentrantLock] to false to exercise the dispatcher queue in concurrency tests. + * Join test work and restore the default before teardown; concurrent overrides are unsupported. * * @return the previous dispatcher, so a test can restore it */ From aec28c8ef92d3fe17f4a1337374074ef0f8d546b Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:21:03 +0100 Subject: [PATCH 30/54] refactor: move showSoftInput to Compat Prep for compileSdk 37: `SHOW_IMPLICIT` is deprecated in API 37. The flag is a no-op from Android 16, so drop it on API 36+ Part of 21672 Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/dialogs/FlagAdapter.kt | 6 ++---- .../main/java/com/ichi2/utils/AndroidUiUtils.kt | 7 +++---- .../java/com/ichi2/anki/compat/BaseCompat.kt | 7 +++++++ .../main/java/com/ichi2/anki/compat/Compat.kt | 17 +++++++++++++++++ .../java/com/ichi2/anki/compat/CompatHelper.kt | 1 + .../java/com/ichi2/anki/compat/CompatV36.kt | 17 +++++++++++++++++ 6 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 compat/src/main/java/com/ichi2/anki/compat/CompatV36.kt 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 f6fc8f9e98f7..1b9fe8b1a904 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FlagAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FlagAdapter.kt @@ -17,11 +17,9 @@ package com.ichi2.anki.dialogs -import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -31,6 +29,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import com.google.android.material.textfield.TextInputEditText import com.ichi2.anki.Flag +import com.ichi2.anki.compat.CompatHelper import com.ichi2.anki.databinding.ItemEditFlagBinding import com.ichi2.utils.moveCursorToEnd import kotlinx.coroutines.CoroutineScope @@ -86,8 +85,7 @@ class FlagAdapter( holder.flagNameEditLayout.visibility = View.VISIBLE holder.flagNameEdit.requestFocus() holder.flagNameEdit.moveCursorToEnd() - val inputMethodManager = holder.flagNameEdit.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.showSoftInput(holder.flagNameEdit, InputMethodManager.SHOW_IMPLICIT) + CompatHelper.compat.showSoftInput(holder.flagNameEdit) } holder.saveButton.setOnClickListener { diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt index 314ebd1ac3b9..82cdbe1d0b85 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.Window import android.view.WindowManager import android.view.inputmethod.InputMethodManager +import com.ichi2.anki.compat.CompatHelper object AndroidUiUtils { /** @@ -18,8 +19,7 @@ object AndroidUiUtils { */ fun Activity?.showSoftInput() { val currentFocus = this?.currentFocus ?: return - val imm = this.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(currentFocus, InputMethodManager.SHOW_IMPLICIT) + CompatHelper.compat.showSoftInput(currentFocus) } /** @@ -48,8 +48,7 @@ object AndroidUiUtils { // Required on some Android 9, 10 devices to show keyboard: https://stackoverflow.com/a/7784904 view.postDelayed({ view.requestFocus() - val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) + CompatHelper.compat.showSoftInput(view) runnable?.run() }, 200) } diff --git a/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt b/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt index f8e1b07a9dd2..2802679fe083 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt @@ -23,6 +23,7 @@ import android.provider.MediaStore import android.view.View import android.view.Window import android.view.WindowManager +import android.view.inputmethod.InputMethodManager import androidx.annotation.AnimRes import androidx.appcompat.widget.TooltipCompat import com.ichi2.anki.common.utils.annotation.KotlinCleanup @@ -54,6 +55,12 @@ open class BaseCompat : Compat { ) } + // Until API36, SHOW_IMPLICIT marks the request as one the system may ignore + override fun showSoftInput(view: View): Boolean { + val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + return imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) + } + override fun overrideTransition( activity: Activity, @AnimRes enter: Int, diff --git a/compat/src/main/java/com/ichi2/anki/compat/Compat.kt b/compat/src/main/java/com/ichi2/anki/compat/Compat.kt index 8746393d843d..21f32fe5e622 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/Compat.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/Compat.kt @@ -80,6 +80,23 @@ interface Compat { fun hideStatusBar(window: Window) + /** + * Request that the current input method's soft input area be shown to the + * user, if needed. + * + * Below Android 16, this is an implicit request: the window may not be + * shown, for example if a hardware keyboard is connected. + * + * @param view The currently focused view, which would like to receive soft keyboard input. + * Note that this view is only considered focused here if both it itself has + * [view focus][View.isFocused], and its containing window has + * [window focus][View.hasWindowFocus]. Otherwise the call fails and returns `false`. + * @return `true` if a request was sent to system_server, `false` otherwise. Note: + * this does not return result of the request. + * @see android.view.inputmethod.InputMethodManager.showSoftInput + */ + fun showSoftInput(view: View): Boolean + fun overrideTransition( activity: Activity, @AnimRes enter: Int, diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt index 6ba97f4429df..2e65b1e09799 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt @@ -40,6 +40,7 @@ class CompatHelper private constructor() { @SuppressLint("NewApi") private val compatValue: Compat = when { + sdkVersion >= Build.VERSION_CODES.BAKLAVA -> CompatV36() sdkVersion >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> CompatV34() sdkVersion >= Build.VERSION_CODES.TIRAMISU -> CompatV33() sdkVersion >= Build.VERSION_CODES.S -> CompatV31() diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatV36.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatV36.kt new file mode 100644 index 000000000000..b839d57d3698 --- /dev/null +++ b/compat/src/main/java/com/ichi2/anki/compat/CompatV36.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.compat + +import android.content.Context +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.annotation.RequiresApi + +@RequiresApi(36) +open class CompatV36 : CompatV34() { + // From API36, SHOW_IMPLICIT is a no-op (and deprecated in API37) + override fun showSoftInput(view: View): Boolean { + val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + return imm.showSoftInput(view, 0) + } +} From dd839aa4fd0caf2eb248c2033c6bd97e6cc22b8d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:35:18 +0100 Subject: [PATCH 31/54] refactor: expose View.showSoftInput Simplifies call sites Part of 21672 Assisted-by: Claude Fable 5 --- .../src/main/java/com/ichi2/anki/dialogs/FlagAdapter.kt | 4 ++-- AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt | 6 +++--- compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt | 7 +++++++ 3 files changed, 12 insertions(+), 5 deletions(-) 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 1b9fe8b1a904..db74b7083326 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FlagAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FlagAdapter.kt @@ -29,7 +29,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import com.google.android.material.textfield.TextInputEditText import com.ichi2.anki.Flag -import com.ichi2.anki.compat.CompatHelper +import com.ichi2.anki.compat.showSoftInput import com.ichi2.anki.databinding.ItemEditFlagBinding import com.ichi2.utils.moveCursorToEnd import kotlinx.coroutines.CoroutineScope @@ -85,7 +85,7 @@ class FlagAdapter( holder.flagNameEditLayout.visibility = View.VISIBLE holder.flagNameEdit.requestFocus() holder.flagNameEdit.moveCursorToEnd() - CompatHelper.compat.showSoftInput(holder.flagNameEdit) + holder.flagNameEdit.showSoftInput() } holder.saveButton.setOnClickListener { diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt index 82cdbe1d0b85..90d92c57f103 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/AndroidUiUtils.kt @@ -8,7 +8,7 @@ import android.view.View import android.view.Window import android.view.WindowManager import android.view.inputmethod.InputMethodManager -import com.ichi2.anki.compat.CompatHelper +import com.ichi2.anki.compat.showSoftInput object AndroidUiUtils { /** @@ -19,7 +19,7 @@ object AndroidUiUtils { */ fun Activity?.showSoftInput() { val currentFocus = this?.currentFocus ?: return - CompatHelper.compat.showSoftInput(currentFocus) + currentFocus.showSoftInput() } /** @@ -48,7 +48,7 @@ object AndroidUiUtils { // Required on some Android 9, 10 devices to show keyboard: https://stackoverflow.com/a/7784904 view.postDelayed({ view.requestFocus() - CompatHelper.compat.showSoftInput(view) + view.showSoftInput() runnable?.run() }, 200) } diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt index 2e65b1e09799..a24c81594c4a 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt @@ -210,6 +210,13 @@ class CompatHelper private constructor() { */ fun View.setTooltipTextCompat(tooltipText: CharSequence?) = TooltipCompat.setTooltipText(this, tooltipText) +/** + * Request that the current input method's soft input area be shown to the user, if needed. + * + * @see Compat.showSoftInput + */ +fun View.showSoftInput(): Boolean = compat.showSoftInput(this) + inline fun Bundle.requireSerializableCompat(key: String): T = requireNotNull(compat.getSerializable(this, key, T::class.java)) { "key: '$key' not found or null" From ba7ce63010f72b5b516ce54b1a72e4df7bf71b27 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:34:00 +0100 Subject: [PATCH 32/54] refactor: use WindowInsetsControllerCompat to hide status bar Prep for compileSdk 37: its annotation metadata declares an empty IntDef for the platform `InsetsType` typedef, so lint reports a `WrongConstant` false positive on `WindowInsetsController.hide`. The androidx annotations are unaffected Part of 21672 Assisted-by: Claude Fable 5 --- compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt index f45e08302d6b..c0b949f9012f 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt @@ -5,7 +5,8 @@ package com.ichi2.anki.compat import android.view.Window import androidx.annotation.RequiresApi -import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat.Type.statusBars @RequiresApi(30) @Suppress("ktlint:standard:property-naming") @@ -21,7 +22,6 @@ open class CompatV30 : CompatV29() { if (view == null) { return } - val controller = view.windowInsetsController - controller?.hide(WindowInsetsCompat.Type.statusBars()) + WindowCompat.getInsetsController(window, view).hide(statusBars()) } } From 7c573be1c425ffd152f898d26cb9caa965f2ddf1 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:38:46 +0100 Subject: [PATCH 33/54] test: pin Robolectric SDK Robolectric 4.16.1 supports a max of SDK 36 Assisted-by: Claude Fable 5 --- api/src/test/java/com/ichi2/anki/api/ApiUtilsTest.kt | 2 ++ .../java/com/ichi2/anki/common/utils/android/ColorUtilsTest.kt | 2 ++ .../java/com/ichi2/anki/common/utils/android/KeyUtilsTest.kt | 2 ++ 3 files changed, 6 insertions(+) diff --git a/api/src/test/java/com/ichi2/anki/api/ApiUtilsTest.kt b/api/src/test/java/com/ichi2/anki/api/ApiUtilsTest.kt index d9ccf9abff6d..209aa69567d3 100644 --- a/api/src/test/java/com/ichi2/anki/api/ApiUtilsTest.kt +++ b/api/src/test/java/com/ichi2/anki/api/ApiUtilsTest.kt @@ -5,6 +5,7 @@ package com.ichi2.anki.api import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import kotlin.test.assertEquals import kotlin.test.assertNull @@ -19,6 +20,7 @@ import kotlin.test.assertNull * Slack: bresan */ @RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) internal class ApiUtilsTest { @Test fun joinFieldsShouldJoinWhenListIsValid() { diff --git a/common/android/src/test/java/com/ichi2/anki/common/utils/android/ColorUtilsTest.kt b/common/android/src/test/java/com/ichi2/anki/common/utils/android/ColorUtilsTest.kt index bda082c6258c..e8e02ecfc5e8 100644 --- a/common/android/src/test/java/com/ichi2/anki/common/utils/android/ColorUtilsTest.kt +++ b/common/android/src/test/java/com/ichi2/anki/common/utils/android/ColorUtilsTest.kt @@ -6,10 +6,12 @@ import android.graphics.Color import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.annotation.Config import kotlin.test.assertEquals /** Tests for [darkenColor] and [lightenColorAbsolute] */ @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class ColorUtilsTest { @Test fun darkenColor_withNoChange_returnsSameColor() { diff --git a/common/android/src/test/java/com/ichi2/anki/common/utils/android/KeyUtilsTest.kt b/common/android/src/test/java/com/ichi2/anki/common/utils/android/KeyUtilsTest.kt index ebf46e7b23cb..cb6c9f6c30d0 100644 --- a/common/android/src/test/java/com/ichi2/anki/common/utils/android/KeyUtilsTest.kt +++ b/common/android/src/test/java/com/ichi2/anki/common/utils/android/KeyUtilsTest.kt @@ -14,11 +14,13 @@ import android.view.KeyEvent.KEYCODE_STAR import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.annotation.Config import kotlin.test.assertEquals import kotlin.test.assertNull /** Tests for [KeyEvent.digit]. */ @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class KeyUtilsTest { @Test fun `digit returns the digit for 0-9 keys`() { From b8994babe76ef0258297ddbb0ff13ca120a38c84 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:34:38 +0100 Subject: [PATCH 34/54] build(deps): bump compileSdk to 37 SDK 37 is the first release under 'major.minor' SDK versioning: the platform ships as `android-37.0`, so `compileSdkMinor` must be set for the target to resolve Fixes 21672 Assisted-by: Claude Fable 5 --- AnkiDroid/build.gradle | 1 + baselineprofile/build.gradle.kts | 4 ++++ buildSrc/src/main/kotlin/ankidroid.android.api.gradle.kts | 1 + buildSrc/src/main/kotlin/ankidroid.android.library.gradle.kts | 1 + gradle/libs.versions.toml | 3 ++- 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 1f10f27c9491..62ec003b5091 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -65,6 +65,7 @@ android { namespace = "com.ichi2.anki" compileSdk = libs.versions.compileSdk.get().toInteger() + compileSdkMinor = libs.versions.compileSdkMinor.get().toInteger() buildFeatures { buildConfig = true diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts index af8215419e79..47de721bcc3b 100644 --- a/baselineprofile/build.gradle.kts +++ b/baselineprofile/build.gradle.kts @@ -13,6 +13,10 @@ configure { libs.versions.compileSdk .get() .toInt() + compileSdkMinor = + libs.versions.compileSdkMinor + .get() + .toInt() compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/buildSrc/src/main/kotlin/ankidroid.android.api.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.android.api.gradle.kts index 7d51e98bdf54..ae67c58407f3 100644 --- a/buildSrc/src/main/kotlin/ankidroid.android.api.gradle.kts +++ b/buildSrc/src/main/kotlin/ankidroid.android.api.gradle.kts @@ -20,6 +20,7 @@ plugins { extensions.configure { compileSdk = libsVersionFor("compileSdk").toInt() + compileSdkMinor = libsVersionFor("compileSdkMinor").toInt() compileOptions { // API remains on VERSION_11 for consumer compatibility. diff --git a/buildSrc/src/main/kotlin/ankidroid.android.library.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.android.library.gradle.kts index 2081d11469c5..8ae226722ae3 100644 --- a/buildSrc/src/main/kotlin/ankidroid.android.library.gradle.kts +++ b/buildSrc/src/main/kotlin/ankidroid.android.library.gradle.kts @@ -16,6 +16,7 @@ plugins { extensions.configure { compileSdk = libsVersionFor("compileSdk").toInt() + compileSdkMinor = libsVersionFor("compileSdkMinor").toInt() defaultConfig { minSdk = libsVersionFor("minSdk").toInt() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2eeb3e6e6f50..4f7339844fde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] -compileSdk = "36" +compileSdk = "37" +compileSdkMinor = "0" # Changing minSdk means newer AnkiDroid versions will not support older devices. # However the Play Store will keep old AnkiDroid versions available for older From f1f1d00169a22b31097ea66c4f207e9297902b2d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:55:15 +0100 Subject: [PATCH 35/54] fix(libanki): clear the notetype cache on undo/redo Matches upstream: pylib/anki/collection.py https://github.com/ankitects/anki/commit/9f3f6bab7dfda8250cc8ff3617c287c6f23e3267 Partially fixes 20510 Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/libanki/Collection.kt | 16 ++++++++-- .../com/ichi2/anki/libanki/NotetypesTest.kt | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt index ab99b1be7580..bc47011ecc66 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt @@ -1226,14 +1226,26 @@ class Collection( * directly mutating the database). */ @LibAnkiAlias("undo") - fun undo(): OpChangesAfterUndo = backend.undo() + fun undo(): OpChangesAfterUndo { + val out = backend.undo() + if (out.changes.notetype) { + notetypes.clearCache() + } + return out + } /** * Returns result of backend redo operation, or throws UndoEmpty. */ @RustCleanup("document exception") @LibAnkiAlias("redo") - fun redo(): OpChangesAfterUndo = backend.redo() + fun redo(): OpChangesAfterUndo { + val out = backend.redo() + if (out.changes.notetype) { + notetypes.clearCache() + } + return out + } @Deprecated("Not implemented") @LibAnkiAlias("op_made_changes") diff --git a/libanki/src/test/java/com/ichi2/anki/libanki/NotetypesTest.kt b/libanki/src/test/java/com/ichi2/anki/libanki/NotetypesTest.kt index 8c48dda5a80c..0550b677777d 100644 --- a/libanki/src/test/java/com/ichi2/anki/libanki/NotetypesTest.kt +++ b/libanki/src/test/java/com/ichi2/anki/libanki/NotetypesTest.kt @@ -16,6 +16,9 @@ package com.ichi2.anki.libanki +import android.annotation.SuppressLint +import anki.notetypes.StockNotetype +import anki.notetypes.notetypeId import com.ichi2.anki.libanki.testutils.InMemoryAnkiTest import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException import net.ankiweb.rsdroid.exceptions.BackendNotFoundException @@ -25,6 +28,32 @@ import org.junit.Test import kotlin.test.assertFailsWith class NotetypesTest : InMemoryAnkiTest() { + @Test // #20510 + fun `notetype cache is invalidated on undo and redo`() { + val notetype = col.notetypes.basic + + fun fieldCount() = + col.notetypes + .get(notetype.id)!! + .fields.size + + col.notetypes.addFieldLegacy(notetype, col.notetypes.newField("NewField")) + assertThat("field added", fieldCount(), equalTo(3)) + + @SuppressLint("CheckResult") + col.notetypes.restoreNotetypeToStock( + notetypeId { ntid = notetype.id }, + forceKind = StockNotetype.Kind.KIND_BASIC, + ) + assertThat("fields after restore", fieldCount(), equalTo(2)) + + col.undo() + assertThat("fields after undo", fieldCount(), equalTo(3)) + + col.redo() + assertThat("fields after redo", fieldCount(), equalTo(2)) + } + @Test fun `getSingleNotetypeOfNotes - multiple`() { val notes = addNotes(2) From ba8ebde34bc0c744e65258ed20dad1779b4388e9 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:11:54 +0100 Subject: [PATCH 36/54] refactor(note-editor): extract onTemplateEditorResult() Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/NoteEditorFragment.kt | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index fc2cf7cb8acf..3b79bddb1bb3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -352,40 +352,44 @@ class NoteEditorFragment : private val requestTemplateEditLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult(), - NoteEditorActivityResultCallback { - // Note type can change regardless of exit type - update ourselves and CardBrowser - reloadRequired = true - editorNote!!.notetype = getColUnsafe.notetypes.get(editorNote!!.noteTypeId)!! - if (currentEditedCard == null || - !editorNote!! - .cardIds(getColUnsafe) - .contains(currentEditedCard!!.id) - ) { - if (!addNote) { - /* This can occur, for example, if the - * card type was deleted or if the note - * type was changed without moving this - * card to another type. */ - Timber.d("onActivityResult() template edit return - current card is gone, close note editor") - showSnackbar(getString(R.string.template_for_current_card_deleted)) - closeNoteEditor() - } else { - Timber.d("onActivityResult() template edit return, in add mode, just re-display") - updateCards(editorNote!!.notetype) - } - } else { - Timber.d("onActivityResult() template edit return - current card exists") - // reload current card - the template ordinals are possibly different post-edit - currentEditedCard = getColUnsafe.getCard(currentEditedCard!!.id) - @NeedsTest("#17282 returning from template editor saves further made changes") - // make sure the card's note is available going forward - currentEditedCard!!.note(getColUnsafe) - editorNote = currentEditedCard!!.note // update the NoteEditor's working note reference - updateCards(editorNote!!.notetype) - } - }, + NoteEditorActivityResultCallback { onTemplateEditorResult() }, ) + /** Handles a return from the [CardTemplateEditor] */ + @VisibleForTesting + internal fun onTemplateEditorResult() { + // Note type can change regardless of exit type - update ourselves and CardBrowser + reloadRequired = true + editorNote!!.notetype = getColUnsafe.notetypes.get(editorNote!!.noteTypeId)!! + if (currentEditedCard == null || + !editorNote!! + .cardIds(getColUnsafe) + .contains(currentEditedCard!!.id) + ) { + if (!addNote) { + /* This can occur, for example, if the + * card type was deleted or if the note + * type was changed without moving this + * card to another type. */ + Timber.d("onActivityResult() template edit return - current card is gone, close note editor") + showSnackbar(getString(R.string.template_for_current_card_deleted)) + closeNoteEditor() + } else { + Timber.d("onActivityResult() template edit return, in add mode, just re-display") + updateCards(editorNote!!.notetype) + } + } else { + Timber.d("onActivityResult() template edit return - current card exists") + // reload current card - the template ordinals are possibly different post-edit + currentEditedCard = getColUnsafe.getCard(currentEditedCard!!.id) + @NeedsTest("#17282 returning from template editor saves further made changes") + // make sure the card's note is available going forward + currentEditedCard!!.note(getColUnsafe) + editorNote = currentEditedCard!!.note // update the NoteEditor's working note reference + updateCards(editorNote!!.notetype) + } + } + private val ioEditorLauncher = registerForActivityResult( ActivityResultContracts.GetContent(), From 7ba9166f6b3751fcf81ae707075f85f9b173154a Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:12:44 +0100 Subject: [PATCH 37/54] fix(note-editor): handle 'Restore to Default' Restoring to default can update the fields in the note type. The Note Editor now rebuilds the fields, preserving text where possible (using the same code as when the spinner is changed) Fixes 20510 Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/NoteEditorFragment.kt | 6 ++- .../java/com/ichi2/anki/NoteEditorTest.kt | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 3b79bddb1bb3..3d3dfebb113d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -376,7 +376,8 @@ class NoteEditorFragment : closeNoteEditor() } else { Timber.d("onActivityResult() template edit return, in add mode, just re-display") - updateCards(editorNote!!.notetype) + // the fields may have changed, so rebuild them + refreshNoteData(FieldChangeType.changeFieldCount(shouldReplaceNewlines())) } } else { Timber.d("onActivityResult() template edit return - current card exists") @@ -386,7 +387,8 @@ class NoteEditorFragment : // make sure the card's note is available going forward currentEditedCard!!.note(getColUnsafe) editorNote = currentEditedCard!!.note // update the NoteEditor's working note reference - updateCards(editorNote!!.notetype) + // the fields may have changed, so rebuild them + setNote(editorNote, FieldChangeType.changeFieldCount(shouldReplaceNewlines())) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt index 8e575ebbdd89..e98a26542a53 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt @@ -4,6 +4,7 @@ package com.ichi2.anki +import android.annotation.SuppressLint import android.app.Activity import android.content.ClipData import android.content.Intent @@ -23,6 +24,8 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.config.ConfigKey +import anki.notetypes.StockNotetype +import anki.notetypes.notetypeId import com.ichi2.anki.NoteEditorTest.FromScreen.DECK_LIST import com.ichi2.anki.NoteEditorTest.FromScreen.REVIEWER import com.ichi2.anki.api.AddContentApi.Companion.DEFAULT_DECK_ID @@ -72,6 +75,49 @@ class NoteEditorTest : RobolectricTest() { ) } + @Test // #20510 + fun `editing - fields are refreshed after the template editor restores the note type to default`() { + val basic = col.notetypes.byName("Basic")!! + col.notetypes.addFieldLegacy(basic, col.notetypes.newField("ThirdField")) + + val editor = getNoteEditorEditingExistingBasicNote("Hello", "World", REVIEWER) + assertThat("field count before restore", editor.editFields!!.size, equalTo(3)) + + // simulate 'Restore to Default' being performed inside the CardTemplateEditor + @SuppressLint("CheckResult") + col.notetypes.restoreNotetypeToStock( + notetypeId { ntid = basic.id }, + StockNotetype.Kind.KIND_BASIC, + ) + editor.onTemplateEditorResult() + + assertThat("field count after restore", editor.editFields!!.size, equalTo(2)) + assertThat(editor.currentFieldStrings.toList(), contains("Hello", "World")) + } + + @Test // #20510 + fun `adding - fields are refreshed after the template editor restores the note type to default`() { + val basic = col.notetypes.byName("Basic")!! + col.notetypes.addFieldLegacy(basic, col.notetypes.newField("ThirdField")) + + val editor = + getNoteEditorAdding(NoteType.BASIC) + .withFirstField("Hello") + .build() + assertThat("field count before restore", editor.editFields!!.size, equalTo(3)) + + // simulate 'Restore to Default' being performed inside the CardTemplateEditor + @SuppressLint("CheckResult") + col.notetypes.restoreNotetypeToStock( + notetypeId { ntid = basic.id }, + StockNotetype.Kind.KIND_BASIC, + ) + editor.onTemplateEditorResult() + + assertThat("field count after restore", editor.editFields!!.size, equalTo(2)) + assertThat("typed text is preserved", editor.currentFieldStrings[0], equalTo("Hello")) + } + @Test fun errorSavingNoteWithNoFirstFieldDisplaysNoFirstField() = runTest { From 574a353876f9e922a5339e6244882996efd3cd47 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:12:33 +0100 Subject: [PATCH 38/54] fix(tests): await filtered deck options destruction Fixes 22030 Assisted-by: GPT-6 --- .../com/ichi2/anki/CardBrowserDeepLinkTest.kt | 41 +------------------ .../com/ichi2/anki/FilteredDeckOptionsTest.kt | 13 ++++++ .../java/com/ichi2/anki/testutil/WaitUtils.kt | 36 ++++++++++++++-- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardBrowserDeepLinkTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardBrowserDeepLinkTest.kt index 6827a71c8af5..5dc29395871b 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardBrowserDeepLinkTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardBrowserDeepLinkTest.kt @@ -2,7 +2,6 @@ package com.ichi2.anki -import android.app.Activity import android.content.Intent import android.os.SystemClock import androidx.core.net.toUri @@ -10,7 +9,7 @@ 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 @@ -18,8 +17,6 @@ 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 /** @@ -90,40 +87,6 @@ class CardBrowserDeepLinkTest : InstrumentedTest() { block: (CardBrowser) -> Unit, ) { testContext.startActivity(deepLink) - val browser = awaitResumed() - 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 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(block) } } diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/FilteredDeckOptionsTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/FilteredDeckOptionsTest.kt index 70f562d7bc9a..575fd5fdf9e1 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/FilteredDeckOptionsTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/FilteredDeckOptionsTest.kt @@ -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 @@ -27,6 +36,10 @@ class FilteredDeckOptionsTest : InstrumentedTest() { val deckId = col.decks.newFiltered("Filtered Testing") deckPicker.viewModel.openDeckOptions(deckId) } + useResumedActivity { + onView(withId(R.id.deck_name_input)) + .checkWithTimeout(matches(allOf(isDisplayed(), withText("Filtered Testing")))) + } } } } diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/WaitUtils.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/WaitUtils.kt index 6dea5fdc4201..cc10d9aacc87 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/WaitUtils.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/WaitUtils.kt @@ -32,14 +32,44 @@ fun waitUntil( } /** - * Polls until an activity of type [T] is resumed. + * Polls until an activity of type [T] is resumed and returns it. * Fails the test if [T] does not resume within [timeout]. */ -inline fun awaitResumedActivity(timeout: Duration = 10.seconds) = +inline fun awaitResumedActivity(timeout: Duration = 10.seconds): T { + var activity: T? = null waitUntil( timeout, message = { "Timed out waiting for ${T::class.java.simpleName}; resumed = ${TestUtils.activityInstance}" }, - ) { TestUtils.activityInstance is T } + ) { + activity = TestUtils.activityInstance as? T + activity != null + } + return checkNotNull(activity) +} + +/** + * Runs [block] on the instrumentation thread with the resumed activity of type [T]. + * Even if [block] fails, closes the activity and awaits destruction before collection cleanup. + */ +inline fun useResumedActivity(block: (T) -> Unit) { + val activity = awaitResumedActivity() + try { + block(activity) + } finally { + activity.finishAndAwaitDestruction() + } +} + +/** Keep the collection open until the activity can no longer access it. */ +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 + } +} /** * Waits for all published [OpChanges] to be delivered to [ChangeManager] subscribers. From 75d95ad0cf0972d3c98041af5b7be85d7d850336 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:27:45 +0100 Subject: [PATCH 39/54] ci(emulator): retry ADB calls during setup ADB can drop offline immediately after the emulator reports boot complete, causing the ART metrics helper to exit before tests start. A retry (up to 5s) may help. Fixes 22033 Assisted-by: GPT-6 --- .github/disable_art_metrics.sh | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/disable_art_metrics.sh b/.github/disable_art_metrics.sh index d3faf16c0faa..caa12017231a 100644 --- a/.github/disable_art_metrics.sh +++ b/.github/disable_art_metrics.sh @@ -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}" @@ -25,15 +42,15 @@ 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 @@ -41,5 +58,5 @@ for ((attempt = 0; attempt < 30; attempt++)); do 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 From 40c66353cde57a4514b9cce82ad1da9cbfdfb9b3 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:32:54 +0100 Subject: [PATCH 40/54] refactor(filtered-decks): sort order position != enum value Keep each backend sort order with its display label and map selections through the explicit order. Prep for filtering out unusable orders. Part of 21926 Assisted-by: GPT-6 --- .../filtered/FilteredDeckOptionsFragment.kt | 2 +- .../anki/filtered/FilteredDeckOptionsState.kt | 14 ++++++++--- .../filtered/FilteredDeckOptionsViewModel.kt | 25 ++++++++++--------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt index 02881bef6cde..c8551ff70dc4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt @@ -368,7 +368,7 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt /** Sets the adapter and selection for cards [Spinner] only if options are available */ private fun Spinner.setAdapterIfNeeded( - cardOptions: List, + cardOptions: List, selectedPosition: Int, ) { if (cardOptions.isNotEmpty()) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsState.kt index cd96bd060972..fdc1dacf88de 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsState.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsState.kt @@ -17,9 +17,9 @@ package com.ichi2.anki.filtered import android.os.Parcelable +import anki.decks.Deck.Filtered.SearchTerm.Order import anki.decks.FilteredDeckForUpdate import com.ichi2.anki.libanki.DeckId -import com.ichi2.anki.libanki.sched.Scheduler import kotlinx.parcelize.Parcelize sealed interface FilteredDeckOptionsState @@ -70,8 +70,8 @@ data class FilteredDeckOptions( * thrown when building/rebuilding results in an empty deck. */ val allowEmpty: Boolean = true, - /** The list if options for cards selection, output of [Scheduler.filteredDeckOrderLabels] */ - val cardOptions: List = emptyList(), + /** Available sort orders, which need not occupy consecutive protobuf enum values. */ + val cardOptions: List = emptyList(), /** The state for the first filter, which is always available */ val filter1State: SearchTermState = SearchTermState(), /** @@ -102,6 +102,14 @@ data class FilteredDeckOptions( ) : Parcelable, FilteredDeckOptionsState +@Parcelize +data class FilteredDeckOrder( + val order: Order, + val label: String, +) : Parcelable { + override fun toString(): String = label +} + /** True if the user is allowed to build/rebuild, false otherwise */ val FilteredDeckOptions.isBuildingAllowed: Boolean get() { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt index 034073dc3eeb..9f890596b34a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import anki.collection.OpChangesWithId -import anki.decks.Deck +import anki.decks.Deck.Filtered.SearchTerm.Order import anki.decks.DeckKt.FilteredKt.searchTerm import anki.decks.DeckKt.filtered import anki.decks.FilteredDeckForUpdate @@ -380,10 +380,13 @@ class FilteredDeckOptionsViewModel( } /** Queries the backend for cards search options and [FilteredDeckForUpdate] guarding against any exception. */ - private fun Collection.safeBackendDataQuery(did: DeckId): Result>> = + private fun Collection.safeBackendDataQuery(did: DeckId): Result>> = try { val filteredDeckForUpdate = sched.getOrCreateFilteredDeck(did) - val cardsOptions = sched.filteredDeckOrderLabels() + val cardsOptions = + sched + .filteredDeckOrderLabels() + .mapIndexed { index, label -> FilteredDeckOrder(Order.forNumber(index), label) } Result.success(Pair(filteredDeckForUpdate, cardsOptions)) } catch (ex: Exception) { Result.failure(ex) @@ -411,10 +414,12 @@ class FilteredDeckOptionsViewModel( * with other pieces of data. */ private fun FilteredDeckForUpdate.asInitialState( - cardsOptions: List = emptyList(), + cardsOptions: List, defaultSearch1: String? = null, defaultSearch2: String? = null, ): FilteredDeckOptions { + fun indexOf(order: Order): Int = cardsOptions.indexOfFirst { it.order == order } + val firstFilter = config.getSearchTerms(0) val secondFilter = if (config.searchTermsCount > 1) config.getSearchTerms(1) else null return FilteredDeckOptions( @@ -429,7 +434,7 @@ class FilteredDeckOptionsViewModel( SearchTermState( search = defaultSearch1 ?: firstFilter.search, limit = firstFilter.limit.toString(), - index = firstFilter.order.number, + index = indexOf(firstFilter.order), ), isSecondFilterEnabled = if (secondFilter != null) id != 0L else false, filter2State = @@ -437,7 +442,7 @@ class FilteredDeckOptionsViewModel( SearchTermState( search = defaultSearch2 ?: it.search, limit = it.limit.toString(), - index = it.order.number, + index = indexOf(it.order), ) }, delayAgain = config.previewAgainSecs.toString(), @@ -464,9 +469,7 @@ class FilteredDeckOptionsViewModel( searchTerm { search = current.filter1State.search limit = current.filter1State.limit.toIntOrNull() ?: 0 - order = - Deck.Filtered.SearchTerm.Order - .forNumber(current.filter1State.index) + order = current.cardOptions[current.filter1State.index].order }, ) val filter2State = current.filter2State @@ -475,9 +478,7 @@ class FilteredDeckOptionsViewModel( searchTerm { search = filter2State.search limit = filter2State.limit.toIntOrNull() ?: 0 - order = - Deck.Filtered.SearchTerm.Order - .forNumber(filter2State.index) + order = current.cardOptions[filter2State.index].order }, ) } From 36e5268927c670febe2d0606c456b67b51a1c82a Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:34:20 +0100 Subject: [PATCH 41/54] fix(filtered-decks): hide FSRS-only sort orders when FSRS is disabled Hide the following if FSRS is disabled: * RETRIEVABILITY_ASCENDING, * RETRIEVABILITY_DESCENDING Fall back to random for invalid saved selections Part of 21926 See https://redirect.github.com/ankitects/anki/pull/5407 Assisted-by: GPT-6 --- .../filtered/FilteredDeckOptionsViewModel.kt | 9 +- .../FilteredDeckOptionsViewModelTest.kt | 86 +++++++++++++++++-- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt index 9f890596b34a..f8ff99e7f60e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt @@ -136,6 +136,7 @@ class FilteredDeckOptionsViewModel( cardOptionIndex: Int, ) { Timber.i("Filtered deck filter($filterIndex) cards options index changing to $cardOptionIndex") + if (cardOptionIndex !in currentState().cardOptions.indices) return if (currentFilterState(filterIndex)?.index == cardOptionIndex) return updateCurrentFilterState(filterIndex) { copy(index = cardOptionIndex) } hasUnsavedChanges.update { wasStateModified() } @@ -383,10 +384,12 @@ class FilteredDeckOptionsViewModel( private fun Collection.safeBackendDataQuery(did: DeckId): Result>> = try { val filteredDeckForUpdate = sched.getOrCreateFilteredDeck(did) + val fsrsEnabled = config.get("fsrs") ?: false val cardsOptions = sched .filteredDeckOrderLabels() .mapIndexed { index, label -> FilteredDeckOrder(Order.forNumber(index), label) } + .filter { fsrsEnabled || it.order !in FSRS_ONLY_ORDERS } Result.success(Pair(filteredDeckForUpdate, cardsOptions)) } catch (ex: Exception) { Result.failure(ex) @@ -418,7 +421,9 @@ class FilteredDeckOptionsViewModel( defaultSearch1: String? = null, defaultSearch2: String? = null, ): FilteredDeckOptions { - fun indexOf(order: Order): Int = cardsOptions.indexOfFirst { it.order == order } + fun indexOf(order: Order): Int = + cardsOptions.indexOfFirst { it.order == order }.takeIf { it >= 0 } + ?: cardsOptions.indexOfFirst { it.order == Order.RANDOM } val firstFilter = config.getSearchTerms(0) val secondFilter = if (config.searchTermsCount > 1) config.getSearchTerms(1) else null @@ -486,6 +491,8 @@ class FilteredDeckOptionsViewModel( } companion object { + private val FSRS_ONLY_ORDERS = setOf(Order.RETRIEVABILITY_ASCENDING, Order.RETRIEVABILITY_DESCENDING) + /** Key used to store/retrieve our state in [SavedStateHandle]. */ private const val ARG_DATA = "arg_data" diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt index 75914ab79897..aeca698fc34c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt @@ -6,6 +6,7 @@ package com.ichi2.anki.filtered import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.decks.Deck +import anki.decks.Deck.Filtered.SearchTerm.Order import anki.decks.DeckKt.FilteredKt.searchTerm import anki.decks.DeckKt.filtered import anki.decks.filteredDeckForUpdate @@ -33,6 +34,74 @@ import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) class FilteredDeckOptionsViewModelTest : RobolectricTest() { + @Test + fun `retrievability orders are hidden without FSRS`() = + runTest { + withCol { config.set("fsrs", false) } + val labels = withCol { sched.filteredDeckOrderLabels() } + withViewModel { + assertFalse(labels[Order.RETRIEVABILITY_ASCENDING.number] in current.cardOptions.map { it.label }) + assertFalse(labels[Order.RETRIEVABILITY_DESCENDING.number] in current.cardOptions.map { it.label }) + } + } + + @Test + fun `saved retrievability orders fall back to random without FSRS`() = + runTest { + withCol { config.set("fsrs", true) } + val did = createTestFilteredDeck(Order.RETRIEVABILITY_ASCENDING, Order.RETRIEVABILITY_DESCENDING) + withCol { config.set("fsrs", false) } + val randomLabel = withCol { sched.filteredDeckOrderLabels()[Order.RANDOM.number] } + withViewModel(did) { + assertEquals(randomLabel, current.cardOptions[current.filter1State.index].label) + assertEquals(randomLabel, current.cardOptions[current.filter2State!!.index].label) + onAllowEmptyChange(true) + build() + assertInstanceOf(state.value) + } + val terms = withCol { sched.getOrCreateFilteredDeck(did).config.searchTermsList } + assertEquals(listOf(Order.RANDOM, Order.RANDOM), terms.map { it.order }) + } + + @Test + fun `retrievability orders survive editing and recreation with FSRS`() = + runTest { + withCol { config.set("fsrs", true) } + val did = createTestFilteredDeck(Order.RETRIEVABILITY_ASCENDING, Order.RETRIEVABILITY_DESCENDING) + val labels = withCol { sched.filteredDeckOrderLabels() } + withRestoredViewModel(did) { + assertEquals(labels[Order.RETRIEVABILITY_ASCENDING.number], current.cardOptions[current.filter1State.index].label) + assertEquals(labels[Order.RETRIEVABILITY_DESCENDING.number], current.cardOptions[current.filter2State!!.index].label) + onAllowEmptyChange(true) + build() + assertInstanceOf(state.value) + } + val terms = withCol { sched.getOrCreateFilteredDeck(did).config.searchTermsList } + assertEquals(listOf(Order.RETRIEVABILITY_ASCENDING, Order.RETRIEVABILITY_DESCENDING), terms.map { it.order }) + } + + @Test + fun `relative overdueness keeps its enum value when FSRS orders are hidden`() = + runTest { + withCol { config.set("fsrs", false) } + val did = createTestFilteredDeck() + withViewModel(did) { + val row = current.cardOptions.indexOfFirst { it.order == Order.RELATIVE_OVERDUENESS } + assertTrue(row >= 0) + onCardsOptionsChange(FilterIndex.First, row) + onCardsOptionsChange(FilterIndex.Second, row) + onAllowEmptyChange(true) + build() + assertInstanceOf(state.value) + } + val terms = withCol { sched.getOrCreateFilteredDeck(did).config.searchTermsList } + assertEquals(listOf(Order.RELATIVE_OVERDUENESS, Order.RELATIVE_OVERDUENESS), terms.map { it.order }) + withViewModel(did) { + assertEquals(Order.RELATIVE_OVERDUENESS, current.cardOptions[current.filter1State.index].order) + assertEquals(Order.RELATIVE_OVERDUENESS, current.cardOptions[current.filter2State!!.index].order) + } + } + @Test fun `building a filtered deck with no cards fails unless allow empty is checked`() = runTest { @@ -339,7 +408,7 @@ class FilteredDeckOptionsViewModelTest : RobolectricTest() { assertTrue(hasUnsavedChanges.value) onLimitChange(FilterIndex.First, "5") assertFalse(hasUnsavedChanges.value) - onCardsOptionsChange(FilterIndex.First, 10) + onCardsOptionsChange(FilterIndex.First, 5) assertTrue(hasUnsavedChanges.value) onCardsOptionsChange(FilterIndex.First, 1) assertFalse(hasUnsavedChanges.value) @@ -357,7 +426,7 @@ class FilteredDeckOptionsViewModelTest : RobolectricTest() { assertTrue(hasUnsavedChanges.value) onLimitChange(FilterIndex.Second, "5") assertFalse(hasUnsavedChanges.value) - onCardsOptionsChange(FilterIndex.Second, 10) + onCardsOptionsChange(FilterIndex.Second, 5) assertTrue(hasUnsavedChanges.value) onCardsOptionsChange(FilterIndex.Second, 1) assertFalse(hasUnsavedChanges.value) @@ -482,7 +551,10 @@ class FilteredDeckOptionsViewModelTest : RobolectricTest() { } /** Note: created filtered deck has the second filter enabled by default */ - private suspend fun createTestFilteredDeck(): DeckId { + private suspend fun createTestFilteredDeck( + firstOrder: Order = Order.forNumber(1), + secondOrder: Order = Order.forNumber(1), + ): DeckId { addDeck("A", true) addNoteToDeckA { flagCardForNote(this, Flag.RED) } addNoteToDeckA { flagCardForNote(this, Flag.GREEN) } @@ -497,18 +569,14 @@ class FilteredDeckOptionsViewModelTest : RobolectricTest() { searchTerm { search = "flag:1" limit = 5 - order = - Deck.Filtered.SearchTerm.Order - .forNumber(1) + order = firstOrder }, ) searchTerms.add( searchTerm { search = "flag:2" limit = 5 - order = - Deck.Filtered.SearchTerm.Order - .forNumber(1) + order = secondOrder }, ) } From 5f85879443b2639d165c0b50af6b6c73bc2939b9 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:45:33 +0100 Subject: [PATCH 42/54] fix: Properly handle spacing for spinner checkmarks * use `InsetDrawable` (8dp) - using a `ldrtl` override for RTL handling Fixes 21030 Assisted-by: Claude Fable 5 - all --- .../anki/dialogs/ChangeNoteTypeDialog.kt | 1 - .../spinner_dropdown_radio_indicator.xml | 9 +++ .../spinner_dropdown_radio_indicator.xml | 14 +++++ .../item_spinner_dropdown_with_radio.xml | 2 +- .../anki/ui/SpinnerDropdownWithRadioTest.kt | 63 +++++++++++++++++++ .../java/com/ichi2/testutils/ContextUtils.kt | 16 +++++ 6 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 AnkiDroid/src/main/res/drawable-ldrtl/spinner_dropdown_radio_indicator.xml create mode 100644 AnkiDroid/src/main/res/drawable/spinner_dropdown_radio_indicator.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ui/SpinnerDropdownWithRadioTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/ContextUtils.kt 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 bb47a9d6d045..798ce00bd53e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt @@ -176,7 +176,6 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note } } - // TODO: Properly handle spacing for spinner checkmarks. Issue not created yet. private fun createNoteTypeAdapter(): ArrayAdapter { val noteTypes = viewModel.availableNoteTypes.map { DisplayNoteType(it.name, it.isCloze) } diff --git a/AnkiDroid/src/main/res/drawable-ldrtl/spinner_dropdown_radio_indicator.xml b/AnkiDroid/src/main/res/drawable-ldrtl/spinner_dropdown_radio_indicator.xml new file mode 100644 index 000000000000..18d289295688 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable-ldrtl/spinner_dropdown_radio_indicator.xml @@ -0,0 +1,9 @@ + + + + diff --git a/AnkiDroid/src/main/res/drawable/spinner_dropdown_radio_indicator.xml b/AnkiDroid/src/main/res/drawable/spinner_dropdown_radio_indicator.xml new file mode 100644 index 000000000000..9d7d6aadbf87 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/spinner_dropdown_radio_indicator.xml @@ -0,0 +1,14 @@ + + + + diff --git a/AnkiDroid/src/main/res/layout/item_spinner_dropdown_with_radio.xml b/AnkiDroid/src/main/res/layout/item_spinner_dropdown_with_radio.xml index 07130db19bdc..ac8e9b66200b 100644 --- a/AnkiDroid/src/main/res/layout/item_spinner_dropdown_with_radio.xml +++ b/AnkiDroid/src/main/res/layout/item_spinner_dropdown_with_radio.xml @@ -11,7 +11,7 @@ Date: Wed, 23 Sep 2026 14:52:42 +0100 Subject: [PATCH 43/54] fix(widget): stop using dynamic colors on startup Use the unthemed layout for widgets on Android 12 and later. This prevents dynamic-color being used. Previously, this was dynamic-color by default, and transitioned to unthemed after the first update. Fixes 22015 Assisted-by: GPT-6 --- .../res/xml-v31/widget_provider_small.xml | 9 ++++ .../AnkiDroidWidgetSmallScreenshotTest.kt | 39 ++++++++++++++ .../anki/widget/AnkiDroidWidgetSmallTest.kt | 53 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 AnkiDroid/src/main/res/xml-v31/widget_provider_small.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/widget/AnkiDroidWidgetSmallScreenshotTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/widget/AnkiDroidWidgetSmallTest.kt diff --git a/AnkiDroid/src/main/res/xml-v31/widget_provider_small.xml b/AnkiDroid/src/main/res/xml-v31/widget_provider_small.xml new file mode 100644 index 000000000000..340d6741c42c --- /dev/null +++ b/AnkiDroid/src/main/res/xml-v31/widget_provider_small.xml @@ -0,0 +1,9 @@ + + + diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnkiDroidWidgetSmallScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnkiDroidWidgetSmallScreenshotTest.kt new file mode 100644 index 000000000000..1a76ebb8a1a3 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnkiDroidWidgetSmallScreenshotTest.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.widget + +import android.os.Build +import android.view.View.MeasureSpec +import androidx.core.view.drawToBitmap +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.captureRoboImage +import com.github.takahirom.roborazzi.provideRoborazziContext +import com.ichi2.anki.ScreenshotTest +import com.ichi2.utils.dp +import org.junit.Test +import org.robolectric.annotation.Config + +/** + * Screenshots of the layout shown before the small widget's first update. + * + * `./gradlew :AnkiDroid:recordRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.widget.AnkiDroidWidgetSmallScreenshotTest"` + */ +@Config(sdk = [Build.VERSION_CODES.R, Build.VERSION_CODES.S]) +class AnkiDroidWidgetSmallScreenshotTest : ScreenshotTest() { + @Test + @OptIn(ExperimentalRoborazziApi::class) + fun initial() { + val classDir = "build/outputs/roborazzi/${javaClass.simpleName}" + val options = provideRoborazziContext().options + withSmallWidget { + val size = 100.dp.toPx(root.context) + val measureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY) + root.measure(measureSpec, measureSpec) + root.layout(0, 0, size, size) + root.drawToBitmap().captureRoboImage( + filePath = "$classDir/${fileNamePrefix}initial_api${Build.VERSION.SDK_INT}.png", + roborazziOptions = options.copy(compareOptions = options.compareOptions.copy(outputDirectoryPath = "$classDir/diffs")), + ) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnkiDroidWidgetSmallTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnkiDroidWidgetSmallTest.kt new file mode 100644 index 000000000000..1fce5380aa81 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnkiDroidWidgetSmallTest.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.widget + +import android.content.Context +import android.os.Build +import android.widget.RemoteViews +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.anki.R +import com.ichi2.anki.databinding.WidgetSmallBinding +import com.ichi2.testutils.EmptyApplication +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.xmlpull.v1.XmlPullParser +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class AnkiDroidWidgetSmallTest { + @Test + @Config(sdk = [Build.VERSION_CODES.S]) + fun `initial widget uses the fixed blue icon on Android 12`() = + withSmallWidget { + assertEquals(R.drawable.ic_anki_unthemed, shadowOf(ankidroidWidgetSmallButton.drawable).createdFromResId) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.R]) + fun `initial widget keeps the legacy icon before Android 12`() = + withSmallWidget { + assertEquals(R.drawable.widget_bg_small, shadowOf(ankidroidWidgetSmallButton.drawable).createdFromResId) + } +} + +/** Inflates the small widget's initial layout for the current Android version and exposes its views. */ +internal fun withSmallWidget(block: WidgetSmallBinding.() -> Unit) { + val context = getApplicationContext() + val initialLayout = + context.resources.getXml(R.xml.widget_provider_small).use { metadata -> + while (metadata.eventType != XmlPullParser.START_TAG) { + metadata.next() + } + metadata.getAttributeResourceValue("http://schemas.android.com/apk/res/android", "initialLayout", 0) + } + val view = RemoteViews(context.packageName, initialLayout).apply(context, null) + WidgetSmallBinding.bind(view).block() +} From 1472bd9f1d5d45bf69ea3e6fe609365dd9a8c4b2 Mon Sep 17 00:00:00 2001 From: Ratnesh5 Date: Tue, 22 Sep 2026 16:52:23 +0530 Subject: [PATCH 44/54] fix: ensure LoadingDialogFragment respects cancellable flag Fixes #21969 --- .../anki/dialogs/LoadingDialogFragment.kt | 2 + .../anki/progress/ProgressObserverTest.kt | 50 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) 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 a2908617d600..53251532c99b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt @@ -24,6 +24,7 @@ class LoadingDialogFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialogView = layoutInflater.inflate(R.layout.fragment_loading, null) val canBeCancelled = arguments?.getBoolean(KEY_CANCELLABLE) ?: true + isCancelable = canBeCancelled dialogView.findViewById(R.id.text).text = arguments?.getString(KEY_MESSAGE) ?: getString(R.string.dialog_processing) return AlertDialog @@ -55,6 +56,7 @@ class LoadingDialogFragment : DialogFragment() { putString(KEY_MESSAGE, message) putBoolean(KEY_CANCELLABLE, cancellable) } + isCancelable = cancellable } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt index 5ac3b7bc3c43..ef33cb102d41 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt @@ -112,11 +112,59 @@ class ProgressObserverTest : RobolectricTest() { op.cancel() } + @Test + fun `newInstance respects cancellable flag`() { + val nonCancellable = LoadingDialogFragment.newInstance("msg", cancellable = false) + assertEquals(false, nonCancellable.isCancelable) + + val cancellable = LoadingDialogFragment.newInstance("msg", cancellable = true) + assertEquals(true, cancellable.isCancelable) + } + + @Test + fun `dialog is not cancellable when op does not provide onCancel`() { + val controller = startActivity() + val activity = controller.get() + activity.observeProgress(viewModel, delayMillis = SHOW_DELAY) { it } + + val gate = CompletableDeferred() + val op = launchOp(gate) + idleMainLooper(SHOW_DELAY * 2) + + val dialogFragment = activity.loadingDialog() as? DialogFragment + assertNotNull(dialogFragment) + assertEquals(false, dialogFragment.isCancelable) + + gate.complete(Unit) + idleMainLooper(SHOW_DELAY) + op.cancel() + } + + @Test + fun `dialog is cancellable when op provides onCancel`() { + val controller = startActivity() + val activity = controller.get() + activity.observeProgress(viewModel, delayMillis = SHOW_DELAY) { it } + + val gate = CompletableDeferred() + val op = launchOp(gate, onCancel = {}) + idleMainLooper(SHOW_DELAY * 2) + + val dialogFragment = activity.loadingDialog() as? DialogFragment + assertNotNull(dialogFragment) + assertEquals(true, dialogFragment.isCancelable) + + gate.complete(Unit) + idleMainLooper(SHOW_DELAY) + op.cancel() + } + private fun launchOp( gate: CompletableDeferred, message: String = "op", + onCancel: (() -> Unit)? = null, ) = CoroutineScope(Dispatchers.Unconfined).launch { - progressManager.withProgress(message = message) { gate.await() } + progressManager.withProgress(message = message, onCancel = onCancel) { gate.await() } } private fun startActivity(): ActivityController = From 61c45938473665596f89e9887553da1555f323b7 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 3 May 2026 12:38:36 +0100 Subject: [PATCH 45/54] fix(deck-options): Easy Days accidentally set [HACK] On a vertical scroll, easy days values were set. This is fixed via a hacky JS wrapper as touch-action:pan-y had no effect Due to the size & maintenance burden of a 'proper' fix [as this code should be temporary], I selected a small, readable piece of JS which disables all touch events, and re-implements 'click'. The 'drag-to-set' functionality of the range slider is lost, but since Easy Days is only three distinct values, and this should be a temporary fix, this feels acceptable Fixes 20929 Assisted-by: Claude Opus 4.7 - implementation --- .../java/com/ichi2/anki/DeckOptionsTest.kt | 125 ++++++++++++++++++ .../java/com/ichi2/anki/pages/DeckOptions.kt | 38 ++++++ 2 files changed, 163 insertions(+) create mode 100644 AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckOptionsTest.kt diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckOptionsTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckOptionsTest.kt new file mode 100644 index 000000000000..7afce61074cf --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckOptionsTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 David Allison + * + * 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 + +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( + """ + (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(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 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 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 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") + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt index 621a872d34eb..8d984dc51175 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt @@ -200,10 +200,48 @@ class DeckOptions : PageFragment() { // clicking a link to the online manual closes the modal and reloads the page onBackFromModal.isEnabled = false listenToModalShowHideEvents() + injectEasyDaysScrollFix() } } } + /** HACK(#20929): vertical scrolling no longer changes values, but drag-to-change is disabled */ + private fun injectEasyDaysScrollFix() { + // TODO: Do this properly if not fixed upstream by 2027 + val js = + """ + (function() { + if (document._easyDaysFix) return; + document._easyDaysFix = true; + + // disable all touch events + const style = document.createElement('style'); + style.textContent = '.easy-days-settings input[type="range"]{pointer-events:none}'; + document.head.appendChild(style); + + // re-implement 'click', so the value can still be set + document.addEventListener('click', (e) => { + const container = e.target.closest('.easy-days-settings .input-container'); + if (!container) return; + const slider = container.querySelector('input[type="range"]'); + + const rect = slider.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + + const min = parseFloat(slider.min); + const max = parseFloat(slider.max); + const step = parseFloat(slider.step) || 1; + const newValue = Math.round((min + (max - min) * fraction) / step) * step; + + if (parseFloat(slider.value) === newValue) return; + slider.value = newValue; + slider.dispatchEvent(new Event('input', { bubbles: true })); + }); + })(); + """.trimIndent() + webViewLayout.evaluateJavascript(js) {} + } + /** * Passes bootstrap modal show/hide events to [ModalJavaScriptInterfaceListener] */ From 51f5fdabb407cdf73f76b06b1a0c416c4c2fc565 Mon Sep 17 00:00:00 2001 From: AnkiDroid Translations Date: Thu, 24 Sep 2026 04:21:10 +0000 Subject: [PATCH 46/54] Updated strings from Crowdin --- AnkiDroid/src/main/res/values-af/01-core.xml | 4 +- .../src/main/res/values-af/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-am/01-core.xml | 4 +- .../src/main/res/values-am/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ar/01-core.xml | 4 +- .../src/main/res/values-ar/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-az/01-core.xml | 4 +- .../src/main/res/values-az/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-be/01-core.xml | 4 +- .../src/main/res/values-be/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-bg/01-core.xml | 4 +- .../src/main/res/values-bg/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-bn/01-core.xml | 4 +- .../src/main/res/values-bn/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ca/01-core.xml | 4 +- .../src/main/res/values-ca/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ckb/01-core.xml | 4 +- .../main/res/values-ckb/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-cs/01-core.xml | 4 +- .../src/main/res/values-cs/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-da/01-core.xml | 4 +- .../src/main/res/values-da/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-de/01-core.xml | 4 +- .../src/main/res/values-de/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-el/01-core.xml | 4 +- .../src/main/res/values-el/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-eo/01-core.xml | 4 +- .../src/main/res/values-eo/10-preferences.xml | 2 +- .../src/main/res/values-es-rAR/01-core.xml | 4 +- .../main/res/values-es-rAR/10-preferences.xml | 2 +- .../src/main/res/values-es-rES/01-core.xml | 4 +- .../main/res/values-es-rES/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-et/01-core.xml | 4 +- .../src/main/res/values-et/02-strings.xml | 142 +++++++++--------- .../src/main/res/values-et/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-eu/01-core.xml | 4 +- .../src/main/res/values-eu/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-fa/01-core.xml | 4 +- .../src/main/res/values-fa/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-fi/01-core.xml | 4 +- .../src/main/res/values-fi/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-fil/01-core.xml | 4 +- .../main/res/values-fil/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-fr/01-core.xml | 6 +- .../src/main/res/values-fr/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-fy/01-core.xml | 4 +- .../src/main/res/values-fy/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ga/01-core.xml | 4 +- .../src/main/res/values-ga/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-gl/01-core.xml | 4 +- .../src/main/res/values-gl/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-got/01-core.xml | 4 +- .../main/res/values-got/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-gu/01-core.xml | 4 +- .../src/main/res/values-gu/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-heb/01-core.xml | 4 +- .../main/res/values-heb/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-hi/01-core.xml | 4 +- .../src/main/res/values-hi/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-hr/01-core.xml | 4 +- .../src/main/res/values-hr/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-hu/01-core.xml | 4 +- .../src/main/res/values-hu/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-hy/01-core.xml | 4 +- .../src/main/res/values-hy/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ind/01-core.xml | 4 +- .../main/res/values-ind/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-it/01-core.xml | 4 +- .../src/main/res/values-it/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-iw/01-core.xml | 4 +- .../src/main/res/values-iw/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ja/01-core.xml | 4 +- .../src/main/res/values-ja/02-strings.xml | 12 +- .../src/main/res/values-ja/03-dialogs.xml | 8 +- .../src/main/res/values-ja/07-cardbrowser.xml | 8 +- .../src/main/res/values-ja/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ka/01-core.xml | 4 +- .../src/main/res/values-ka/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-kk/01-core.xml | 4 +- .../src/main/res/values-kk/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-km/01-core.xml | 4 +- .../src/main/res/values-km/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-kn/01-core.xml | 4 +- .../src/main/res/values-kn/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ko/01-core.xml | 4 +- .../src/main/res/values-ko/03-dialogs.xml | 8 +- .../src/main/res/values-ko/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ku/01-core.xml | 4 +- .../src/main/res/values-ku/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ky/01-core.xml | 4 +- .../src/main/res/values-ky/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-lt/01-core.xml | 4 +- .../src/main/res/values-lt/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-lv/01-core.xml | 4 +- .../src/main/res/values-lv/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-mk/01-core.xml | 4 +- .../src/main/res/values-mk/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ml/01-core.xml | 4 +- .../src/main/res/values-ml/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-mn/01-core.xml | 4 +- .../src/main/res/values-mn/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-mr/01-core.xml | 4 +- .../src/main/res/values-mr/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ms/01-core.xml | 4 +- .../src/main/res/values-ms/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-my/01-core.xml | 4 +- .../src/main/res/values-my/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-nl/01-core.xml | 4 +- AnkiDroid/src/main/res/values-nn/01-core.xml | 4 +- .../src/main/res/values-nn/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-no/01-core.xml | 4 +- .../src/main/res/values-no/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-or/01-core.xml | 4 +- .../src/main/res/values-or/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-pa/01-core.xml | 4 +- .../src/main/res/values-pa/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-pl/01-core.xml | 4 +- .../src/main/res/values-pl/10-preferences.xml | 6 +- .../src/main/res/values-pt-rBR/01-core.xml | 4 +- .../main/res/values-pt-rBR/10-preferences.xml | 2 +- .../src/main/res/values-pt-rPT/01-core.xml | 4 +- .../main/res/values-pt-rPT/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ro/01-core.xml | 4 +- .../src/main/res/values-ro/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ru/01-core.xml | 4 +- .../src/main/res/values-ru/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-sat/01-core.xml | 4 +- .../main/res/values-sat/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-sc/01-core.xml | 4 +- .../src/main/res/values-sc/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-sk/01-core.xml | 4 +- .../src/main/res/values-sk/02-strings.xml | 2 +- .../src/main/res/values-sk/03-dialogs.xml | 12 +- .../src/main/res/values-sk/07-cardbrowser.xml | 2 +- .../src/main/res/values-sk/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-sl/01-core.xml | 4 +- .../src/main/res/values-sl/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-sq/01-core.xml | 4 +- .../src/main/res/values-sq/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-sr/01-core.xml | 4 +- .../src/main/res/values-sr/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-sv/01-core.xml | 4 +- .../src/main/res/values-sv/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ta/01-core.xml | 4 +- .../src/main/res/values-ta/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-te/01-core.xml | 4 +- .../src/main/res/values-te/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-tgl/01-core.xml | 4 +- .../main/res/values-tgl/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-th/01-core.xml | 4 +- .../src/main/res/values-th/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ti/01-core.xml | 4 +- .../src/main/res/values-ti/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-tr/01-core.xml | 4 +- .../src/main/res/values-tr/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-tt/01-core.xml | 4 +- .../src/main/res/values-tt/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ug/01-core.xml | 4 +- .../src/main/res/values-ug/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-uk/01-core.xml | 4 +- .../src/main/res/values-uk/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-ur/01-core.xml | 4 +- .../src/main/res/values-ur/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-uz/01-core.xml | 4 +- .../src/main/res/values-uz/10-preferences.xml | 2 +- AnkiDroid/src/main/res/values-vi/01-core.xml | 4 +- .../src/main/res/values-vi/02-strings.xml | 6 +- .../src/main/res/values-vi/10-preferences.xml | 2 +- .../src/main/res/values-zh-rCN/01-core.xml | 4 +- .../main/res/values-zh-rCN/10-preferences.xml | 2 +- .../src/main/res/values-zh-rTW/01-core.xml | 4 +- .../main/res/values-zh-rTW/10-preferences.xml | 2 +- 172 files changed, 348 insertions(+), 348 deletions(-) diff --git a/AnkiDroid/src/main/res/values-af/01-core.xml b/AnkiDroid/src/main/res/values-af/01-core.xml index fe18f5003136..374fe27a0485 100644 --- a/AnkiDroid/src/main/res/values-af/01-core.xml +++ b/AnkiDroid/src/main/res/values-af/01-core.xml @@ -127,8 +127,8 @@ Lees en skryf na die AnkiDroid databasis toegang tot bestaande notas, kaarte, notatipes en pakke, sowel as om nuwe te skep - Kaart blaaier - Anki kaart + Card browser + Anki card Die versamelings kan nie gekombineer word nie.\nWatter versameligs wil jy hou? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-af/10-preferences.xml b/AnkiDroid/src/main/res/values-af/10-preferences.xml index c4b5c70e00b3..af4c66c9b022 100644 --- a/AnkiDroid/src/main/res/values-af/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-af/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-am/01-core.xml b/AnkiDroid/src/main/res/values-am/01-core.xml index 84502285b5a6..009710ef14f5 100644 --- a/AnkiDroid/src/main/res/values-am/01-core.xml +++ b/AnkiDroid/src/main/res/values-am/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-am/10-preferences.xml b/AnkiDroid/src/main/res/values-am/10-preferences.xml index 1f2fd228c45e..fca2e8cb68c3 100644 --- a/AnkiDroid/src/main/res/values-am/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-am/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-ar/01-core.xml b/AnkiDroid/src/main/res/values-ar/01-core.xml index c8e8eff361a3..51f0578dee3e 100644 --- a/AnkiDroid/src/main/res/values-ar/01-core.xml +++ b/AnkiDroid/src/main/res/values-ar/01-core.xml @@ -147,8 +147,8 @@ القراءة والكتابة إلى قاعدة بيانات آنكي درويد الوصول إلى الملحوظات والبطاقات وأنواع الملحوظات والرزم الموجودة بالإضافة إلى إنشاء أخرى - متصفح البطاقات - بطاقة آنكي + Card browser + Anki card لا يمكن دمج المجموعات.\nما هي المجموعة التي تريد الاحتفاظ بها؟ آنكي درويد diff --git a/AnkiDroid/src/main/res/values-ar/10-preferences.xml b/AnkiDroid/src/main/res/values-ar/10-preferences.xml index 6a9e2dc21858..b09452200de1 100644 --- a/AnkiDroid/src/main/res/values-ar/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ar/10-preferences.xml @@ -111,7 +111,7 @@ النقر بثلاث أصابع النقر بأربع أصابع رج الجهاز - قائمة «%s» + ‘%s’ menu يفعل قائمة سياق «%s» في عموم الجهاز مضاعفة فجوة التمرير مضاعفة فجوة التمرير مع القارئ الإلكتروني diff --git a/AnkiDroid/src/main/res/values-az/01-core.xml b/AnkiDroid/src/main/res/values-az/01-core.xml index 496be522809a..4674857cec1b 100644 --- a/AnkiDroid/src/main/res/values-az/01-core.xml +++ b/AnkiDroid/src/main/res/values-az/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-az/10-preferences.xml b/AnkiDroid/src/main/res/values-az/10-preferences.xml index ef20feb5bb89..47fba78a508a 100644 --- a/AnkiDroid/src/main/res/values-az/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-az/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Cihazı salla - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-be/01-core.xml b/AnkiDroid/src/main/res/values-be/01-core.xml index 8b23b629f2ef..0ffd6fcbfbc4 100644 --- a/AnkiDroid/src/main/res/values-be/01-core.xml +++ b/AnkiDroid/src/main/res/values-be/01-core.xml @@ -137,8 +137,8 @@ Чытанне і запіс у базу даных AnkiDroid доступ да існуючых нататак, картак, тыпаў нататак і калод, а таксама стварэнне новых - Аглядальнік картак - Картка Anki + Card browser + Anki card Гэтыя калекцыі не могуць быць аб\'яднаны.\nЯкую калекцыю вы хочаце пакінуць? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-be/10-preferences.xml b/AnkiDroid/src/main/res/values-be/10-preferences.xml index 46f358d49494..8b319441cfe2 100644 --- a/AnkiDroid/src/main/res/values-be/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-be/10-preferences.xml @@ -109,7 +109,7 @@ Націск трыма пальцамі Націск чатырма пальцамі Патрасіце прыладу - Меню «%s» + ‘%s’ menu Уключыць кантэкстнае меню «%s» у сістэме Падвоеная прагортка Падвоены разрыў прагорткі ў eReader diff --git a/AnkiDroid/src/main/res/values-bg/01-core.xml b/AnkiDroid/src/main/res/values-bg/01-core.xml index c9398f311dd7..75db9544f591 100644 --- a/AnkiDroid/src/main/res/values-bg/01-core.xml +++ b/AnkiDroid/src/main/res/values-bg/01-core.xml @@ -127,8 +127,8 @@ Четене и писане в базата от данни на AnkiDroid достъп до съществуващи бележки, карти, типове бележки и тестета, както и създаване на нови - Браузър за карти - карта Anki + Card browser + Anki card Колекциите не могат да бъдат обединени.\nКоя от тях искате да запазите? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-bg/10-preferences.xml b/AnkiDroid/src/main/res/values-bg/10-preferences.xml index 577f19c93673..d899e2dcdb15 100644 --- a/AnkiDroid/src/main/res/values-bg/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-bg/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Двойно превъртане Удвояване на разстоянието при превъртането при е-четци diff --git a/AnkiDroid/src/main/res/values-bn/01-core.xml b/AnkiDroid/src/main/res/values-bn/01-core.xml index c6819b9ef250..3daed67d93a7 100644 --- a/AnkiDroid/src/main/res/values-bn/01-core.xml +++ b/AnkiDroid/src/main/res/values-bn/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ডাটাবেস থেকে পড়ুন এবং লিখুন বিদ্যমান নোট, কার্ড, নোটের ধরন এবং ডেক এ প্রবেশের পাশাপাশি নতুন একটি তৈরি করুন - কার্ড ব্রাউজার - Anki কার্ড + Card browser + Anki card সংকলনগুলি সমন্বয় করা যাচ্ছেনা। কোন সংকলনটি আপনি রাখতে চান? আনকি-ড্রয়েড diff --git a/AnkiDroid/src/main/res/values-bn/10-preferences.xml b/AnkiDroid/src/main/res/values-bn/10-preferences.xml index 23d3fd3a685a..b61f44d2c244 100644 --- a/AnkiDroid/src/main/res/values-bn/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-bn/10-preferences.xml @@ -108,7 +108,7 @@ Three-finger tap Four-finger tap ডিভাইস ঝাঁকান - \'%s\' মেনু + ‘%s’ menu বিশ্বব্যাপী \'%s\' প্রসঙ্গ মেনু সক্রিয় করে ডাবল স্ক্রলিং eReader এর সাথে স্ক্রোল ব্যবধান দ্বিগুণ করুন diff --git a/AnkiDroid/src/main/res/values-ca/01-core.xml b/AnkiDroid/src/main/res/values-ca/01-core.xml index f4a595a84aad..b20c5708bd89 100644 --- a/AnkiDroid/src/main/res/values-ca/01-core.xml +++ b/AnkiDroid/src/main/res/values-ca/01-core.xml @@ -127,8 +127,8 @@ Llegir i escriure a la base de dades d\'AnkiDroid accés a notes, targetes, tipus de nota i malls existents, així com crear-ne de nous - Navegador de cartes - Menú \'Anki Card\' + Card browser + Anki card Les col·leccions no es poden combinar.\nQuina col·lecció vols conservar? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ca/10-preferences.xml b/AnkiDroid/src/main/res/values-ca/10-preferences.xml index 8847dc07dcdd..a1c0df2afa6f 100644 --- a/AnkiDroid/src/main/res/values-ca/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ca/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - Menú ‘%s’ + ‘%s’ menu Permet a tot arreu el menú contextual \'%s\' Desplaçament doble Doble el scrolling amb l\'eReader diff --git a/AnkiDroid/src/main/res/values-ckb/01-core.xml b/AnkiDroid/src/main/res/values-ckb/01-core.xml index 0a4104898f20..2bc00e799ab0 100644 --- a/AnkiDroid/src/main/res/values-ckb/01-core.xml +++ b/AnkiDroid/src/main/res/values-ckb/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - هەڵدەرەوەی کارت - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ckb/10-preferences.xml b/AnkiDroid/src/main/res/values-ckb/10-preferences.xml index 4b7dfc4d5309..3df74e2d5e26 100644 --- a/AnkiDroid/src/main/res/values-ckb/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ckb/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-cs/01-core.xml b/AnkiDroid/src/main/res/values-cs/01-core.xml index 4d4be39832fd..eae19da124b4 100644 --- a/AnkiDroid/src/main/res/values-cs/01-core.xml +++ b/AnkiDroid/src/main/res/values-cs/01-core.xml @@ -137,8 +137,8 @@ Čtení a zápis do AnkiDroid databáze spravovat stávající poznámky, karty, typy poznámek a balíčky, a také vytvářet nové - Prohlížeč karet - Anki karta + Card browser + Anki card Kolekce nelze zkombinovat.\nKterou kolekci chcete ponechat? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-cs/10-preferences.xml b/AnkiDroid/src/main/res/values-cs/10-preferences.xml index 9a0715efc79c..729a7dfffd63 100644 --- a/AnkiDroid/src/main/res/values-cs/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-cs/10-preferences.xml @@ -109,7 +109,7 @@ Klepnutí třemi prsty Klepnutí čtyřmi prsty Zatřesení zařízením - Menu „%s“ + ‘%s’ menu Povolí globální kontextovou nabídku „%s“ Dvojitý posun Dvojitý posuvník s eReader diff --git a/AnkiDroid/src/main/res/values-da/01-core.xml b/AnkiDroid/src/main/res/values-da/01-core.xml index ac6bc58b4f93..957464ca2006 100644 --- a/AnkiDroid/src/main/res/values-da/01-core.xml +++ b/AnkiDroid/src/main/res/values-da/01-core.xml @@ -127,8 +127,8 @@ Læse og skrive til AnkiDroid-databasen tilgå eksisterende noter, kort, notetyper og stakke, og skabe nye - Kortbrowser - Anki-kort + Card browser + Anki card Samlingerne kan ikke kombineres.\nHvilken samling vil du beholde? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-da/10-preferences.xml b/AnkiDroid/src/main/res/values-da/10-preferences.xml index 568e63564868..f4a65c9a0c03 100644 --- a/AnkiDroid/src/main/res/values-da/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-da/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-de/01-core.xml b/AnkiDroid/src/main/res/values-de/01-core.xml index ae7199e43d20..0bf1c9885653 100644 --- a/AnkiDroid/src/main/res/values-de/01-core.xml +++ b/AnkiDroid/src/main/res/values-de/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid-Datenbank lesen und schreiben Zugriff auf bereits vorhandene Notizen, Karten, Notiztypen und Stapel, sowie deren Neuerstellung - Kartenübersicht - Anki-Karte + Card browser + Anki card Die Sammlungen können nicht zusammengeführt werden.\nWelche Sammlung möchten Sie behalten? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-de/10-preferences.xml b/AnkiDroid/src/main/res/values-de/10-preferences.xml index 102641454111..ff1cd179461c 100644 --- a/AnkiDroid/src/main/res/values-de/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-de/10-preferences.xml @@ -107,7 +107,7 @@ Drei-Finger-Tippen Vier-Finger-Tippen Gerät schütteln - »%s«-Menü + ‘%s’ menu Aktiviert global das Kontextmenü »%s« Doppeltes scrollen Auf eReadern mit doppelter Schrittgröße scrollen diff --git a/AnkiDroid/src/main/res/values-el/01-core.xml b/AnkiDroid/src/main/res/values-el/01-core.xml index aa5ff4e8dfb0..6b598cec0778 100644 --- a/AnkiDroid/src/main/res/values-el/01-core.xml +++ b/AnkiDroid/src/main/res/values-el/01-core.xml @@ -127,8 +127,8 @@ Ανάγνωση και εγγραφή στη βάση δεδομένων του AnkiDroid πρόσβαση σε υπάρχουσες σημειώσεις, κάρτες, τύπους σημειώσεων και τράπουλες, καθώς και δημιουργία νέων - Ευρετήριο καρτών - Κάρτα Anki + Card browser + Anki card Οι συλλογές δεν μπορούν να συνδυαστούν.\nΠοια συλλογή θέλετε να κρατήσετε; AnkiDroid diff --git a/AnkiDroid/src/main/res/values-el/10-preferences.xml b/AnkiDroid/src/main/res/values-el/10-preferences.xml index 3a6fcd5810f8..dadd645555a7 100644 --- a/AnkiDroid/src/main/res/values-el/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-el/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Ανακίνηση συσκευής - Μενού ‘%s’ + ‘%s’ menu Enables the ‘%s’ context menu globally Διπλή κύλιση Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-eo/01-core.xml b/AnkiDroid/src/main/res/values-eo/01-core.xml index 531074f5a0bf..6d2b4825f390 100644 --- a/AnkiDroid/src/main/res/values-eo/01-core.xml +++ b/AnkiDroid/src/main/res/values-eo/01-core.xml @@ -127,8 +127,8 @@ Legi kaj skribi al la datumbazo de AnkiDroid aliri ekzistajn notojn, kartojn, nototipojn kaj kartarojn, kaj krei novajn - Kart-foliumilo - Anki-karto + Card browser + Anki card Ne povas kombini la kolektojn.\nKiun kolekton vi volas konservi? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-eo/10-preferences.xml b/AnkiDroid/src/main/res/values-eo/10-preferences.xml index f661e9666c51..200705c1249a 100644 --- a/AnkiDroid/src/main/res/values-eo/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-eo/10-preferences.xml @@ -107,7 +107,7 @@ Tri-fingra frapeto Kvar-fingra frapeto Skui aparaton - Menuo “%s” + ‘%s’ menu Aktivigi la kuntekstan menuon “%s” tutaplikaĵe Duobla rulumado Duobligi distancon de rulumado kun elektronika legilo diff --git a/AnkiDroid/src/main/res/values-es-rAR/01-core.xml b/AnkiDroid/src/main/res/values-es-rAR/01-core.xml index 6642783ee491..591df27272a5 100644 --- a/AnkiDroid/src/main/res/values-es-rAR/01-core.xml +++ b/AnkiDroid/src/main/res/values-es-rAR/01-core.xml @@ -127,8 +127,8 @@ Leer y escribir en la base de datos de AnkiDroid acceder a las notas existentes, tarjetas, tipos de nota y mazos, así como crear nuevos - Explorador de tarjetas - Tarjeta Anki + Card browser + Anki card Las colecciones no se pueden combinar.\n¿Qué colección quieres mantener? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-es-rAR/10-preferences.xml b/AnkiDroid/src/main/res/values-es-rAR/10-preferences.xml index 175eeb6747ab..77438f9151cd 100644 --- a/AnkiDroid/src/main/res/values-es-rAR/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-es-rAR/10-preferences.xml @@ -107,7 +107,7 @@ Toque con tres dedos Toque con cuatro dedos Sacudir dispositivo - ‘%s’ Menú + ‘%s’ menu Habilitar globalmente el menú contextual ‘%s Doble desplazamiento Doblar la distancia de desplazamiento con e-Reader diff --git a/AnkiDroid/src/main/res/values-es-rES/01-core.xml b/AnkiDroid/src/main/res/values-es-rES/01-core.xml index f9d9a343cb1b..1d6557ff8d7e 100644 --- a/AnkiDroid/src/main/res/values-es-rES/01-core.xml +++ b/AnkiDroid/src/main/res/values-es-rES/01-core.xml @@ -127,8 +127,8 @@ Leer y escribir en la base de datos de AnkiDroid acceder a notas, tarjetas, tipo de notas, y mazos existentes, así como crear otros nuevos - Explorador de tarjetas - Tarjeta Anki + Card browser + Anki card Las colecciones no se pueden combinar.\n¿Qué colección quieres mantener? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-es-rES/10-preferences.xml b/AnkiDroid/src/main/res/values-es-rES/10-preferences.xml index e1a501a46ff5..cfa7a99ca035 100644 --- a/AnkiDroid/src/main/res/values-es-rES/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-es-rES/10-preferences.xml @@ -107,7 +107,7 @@ Toque con tres dedos Toque con cuatro dedos Agitar el dispositivo - Menú ‘%s’ + ‘%s’ menu Habilita globalmente el menú contextual ‘%s Desplazamiento doble Doblar la distancia de desplazamiento con e-Reader diff --git a/AnkiDroid/src/main/res/values-et/01-core.xml b/AnkiDroid/src/main/res/values-et/01-core.xml index c2eaf4889f40..3bb5f8aa31cc 100644 --- a/AnkiDroid/src/main/res/values-et/01-core.xml +++ b/AnkiDroid/src/main/res/values-et/01-core.xml @@ -127,8 +127,8 @@ Loe ja kirjuta AnkiDroid andmebaasi ligi pääseda olemasolevatele ja luua uusi märkmeid, kaarte, märketüüpe ja kaardipakke. - Kaardibrauser - Anki Kaart + Card browser + Anki card Kogusid pole võimalik ühendada.\nMillise soovite alles hoida? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-et/02-strings.xml b/AnkiDroid/src/main/res/values-et/02-strings.xml index c6abcb85e099..e548ea83f83f 100644 --- a/AnkiDroid/src/main/res/values-et/02-strings.xml +++ b/AnkiDroid/src/main/res/values-et/02-strings.xml @@ -26,7 +26,7 @@ Ava sahtel Sulge sahtel - Note saved + Märkus salvestatud Kaardipakk: Kaardipakk: @@ -52,7 +52,7 @@ Paremal Lisavalikud Are you sure you want to remove this brush from your palette? - Ink + Tint Stroke Stylus mode The last brush may not be removed @@ -69,14 +69,14 @@ Card marked as leech Tundmatu viga Selle põhjustas WebView\'i siseviga - Out of memory + Mälu pole piisavalt The WebView was terminated by the system. This typically happens when the application runs out of memory, often due to large fonts or media. WebView renderer crashed. Cause: %s Fatal error: WebView renderer crashed. Cause: %s System WebView rendering failure System WebView failed to render card \'%1$s\'.\n %2$s Tühistamiseks libista - Flags + Märgistused Delay for again Delay for hard @@ -88,12 +88,12 @@ +%d buried +%d buried - Total new cards - Total cards + Kokku uusi kaarte + Kaarte kokku Muuda märkust - Discard - No cards created. Please fill in more fields + Hülga + Ühtegi kaarti ei loodud. Palun täida rohkem välju The current note type did not produce any cards.\nPlease choose another note type, or click ‘Cards’ and add a field substitution Cloze deletions will only work on a Cloze note type Märge salvestatakse @@ -102,18 +102,18 @@ Sulge Vali %1$s (from “%2$s”) - No note type found - %1$s (empty) + Märkme liiki ei leitud + %1$s (tühi) Pööra Peegelda - Flip horizontally - Flip vertically + Pööra horisontaalselt + Pööra vertikaalselt Timebox reached - %1$d card studied in %2$s - %1$d cards studied in %2$s + %1$d kaarti õpitud: %2$s + %1$d kaarti õpitud: %2$s @@ -124,13 +124,13 @@ Kopeeri märge Paiguta ümber Lähtestamise progress - Reschedule + Ajasta ümber Eelvaade - Copy as Markdown - %1$d of %2$d + Kopeeri Markdownina + %1$d/%2$d Nimeta ümber Kontrolli - Type answer: unknown field %s + Kirjuta vastus: tundmatu väli %s Kustutan kaardipakki… Hinda AnkiDroidi Importimine @@ -144,10 +144,10 @@ Filename “%s” doesn’t have .apkg or .colpkg extension Anki Database (.anki2) replacements are not yet supported. Please see the manual for replacement instructions. The selected file couldn’t be imported automatically by AnkiDroid. Please see the user manual for how to manually import Anki files: \n%s - Failed to cache file + Faili vahemällu lugemine ebaõnnestus This is often caused by a bug in the app you used to select the file. Please try the following fixes:\n\n%s Importimine katkes: AnkiDroid suleti - Export ready + Eksportimine on valmis Puudub rakendus apkg käsitsemiseks. Salvestamine… Anki paki salvestamine õnnestus Anki paki salvestamine ebaõnnestus @@ -170,8 +170,8 @@ This is a special deck for studying outside of the normal schedule. Cards will be automatically returned to their original decks after you review them. Deleting this deck from the deck list will return all remaining cards to their original deck. Touch “%2$s” to confirm adding “%1$s” Existing tag “%1$s” selected - Tag already exists - Name already exists + Silt on juba olemas + Nimi on juba olemas Consider backing up your collection from DeckPicker before using this option! @@ -187,14 +187,14 @@ Eemaldage taust Are you sure you wish to remove the background? - Restore default + Taasta vaikeväärtused - Eraser mode activated - Eraser mode deactivated + Kustutamise režiim on aktiveeritud + Kustutamise režiim on deaktiveeritud %1$s/%2$s - Media too large + Meediafail on liiga suur The file \"%1$s\" (%2$s) exceeds the AnkiWeb limit of %3$s and cannot be synced. Add anyway? - Add anyway + Lisa sellegipoolest Failed to save whiteboard image. %s Whiteboard image saved to %s @@ -214,39 +214,39 @@ Card Content Error: Failed to load ‘%s’ Otsi kaardipakke - Fatal error + Kriitiline tõrge AnkiDroid relies on the System WebView which is unavailable. This can happen if the system is installing updates. Please try again in a few minutes.\n\n%s AnkiDroid cannot access storage due to an Android error.\n\n%s Teksti suurus Näita tööriistariba - Format as bold - Format as italic - Format as underline - Insert horizontal line - Insert heading - Change font size - Insert MathJax equation + Rasvane kiri + Kaldkiri + Allajoonitud + Sisesta vahejoon + Sisesta pealkiri + Muuda teksti suurust + Sisesta MathJax tehe Nupu tekst - HTML before selection - HTML after selection - Create toolbar item - Edit toolbar item + HTML enne valikut + HTML pärast valikut + Loo tööriista kirje + Muuda tööriista kirjet Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it - Remove toolbar item? - The image is too large, please insert the image manually - The video file is too large, please insert the video manually - The audio file is too large, please insert the audio manually - Android backup in progress. Please try again + Kas eemaldada tööriista kirje? + Pildifail on liiga suur. Palun sisesta pilt käsitsi + Videofail on liiga suur. Palun sisesta video käsitsi + Helifail on liiga suur. Palun sisesta heli käsitsi + Androidi varundamine on käimas. Palun proovi uuesti You may need to use iManager to allow AnkiDroid to add shortcuts Allow ‘Home screen shortcuts’ in system settings for %s to add shortcuts Your home screen does not allow AnkiDroid to add shortcuts Error adding shortcut: %s - Capitalize sentences + Suurtäht lause alguses Keri tööriistariba URL - Certificate + Sertifikaat + - @@ -256,16 +256,16 @@ Sisestage kehtiv e-posti aadress Parool on nõutud - Go back again to exit + Mine tagasi, et väljuda %s%% Laadi alla kaardipakk Ava brauseris - Try again + Proovi uuesti Katkesta allalaadimine Katkestada allalaadimine? Allalaadimine %s - Import deck + Impordi kaardipakk Midagi läks valesti. Palun proovi uuesti Allalaadimine ebaõnnestus You can use other apps while the download is running @@ -293,16 +293,16 @@ Laienda silti \'%s\' Failed to access collection. Please try again! - Add a new note type + Lisa uus märkme liik - %d note type selected - %d note types selected + %d märkme liik valitud + %d märkmeliiki valitud Õpi vähem Mäleta rohkem Anki kaarditega saad säästa aega oma nõrgimate mälestuste tugevdamiseks ja tugevamate mälestuste säilitamiseks - Get started + Alusta Sünkrooni AnkiWebist AnkiDroid is not initialized yet. Please open AnkiDroid and try again @@ -315,8 +315,8 @@ Kaardid Märkmed Truncate the height of each row of the Browser to show only first 3 lines of content - Recording saved - Deleting selected notes + Salvestis on talletatud + Valitud märkmete kustutamine Tap a voice to listen Voice should be installed before use Kasuta sellegipoolest @@ -324,11 +324,11 @@ Internet Paigalda - Failed to open text to speech settings - Please log in to download more decks + Teksti kõneks seadete avamine ebaõnnestus + Palun logi sisse, et laadida alla rohkem kaardipakke Kirjeldus Kirjeldust on uuendatud - Format as Markdown + Vorminda kui Markdown Open help for ‘%s’ Kopeerimine ebaõnnestus @@ -343,20 +343,20 @@ Esita Järgmine - Cannot delete card type - Deleting this card type will leave some notes without any cards. - Voice not supported. Try another or install a voice engine. + Kaardi liiki ei saa kustutada + Selle kaardiliigi kustutamine jätab mõned märkmed ilma kaartideta. + Heli pole toetatud. Proovi mõnda teist helimootorit. - Deck picker + Kaardipaki valija Kustuta kaardipakk ilma kinnitust küsimata - Note editor + Märkmete redaktor Vali märkme liik Siltide redaktor - Card template editor + Kaardi mallide redaktor Muuda esikülje malli Muuda tagakülje malli Muuda stiili - Copy template as markdown + Kopeeri mall kui markdown Muuda brauseri välimust User action %s is not set in this notetype. Please configure it This card has features that are not yet supported by the new study screen. @@ -366,11 +366,11 @@ , - Day - Date range - Already in progress + Päev + Kuupäevade vahemik + Juba töös - Switch profile - Edit profile - Delete profile + Vaheta profiili + Muuda profiili + Kustuta profiil diff --git a/AnkiDroid/src/main/res/values-et/10-preferences.xml b/AnkiDroid/src/main/res/values-et/10-preferences.xml index 36e79e68e03c..793369e71e6d 100644 --- a/AnkiDroid/src/main/res/values-et/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-et/10-preferences.xml @@ -107,7 +107,7 @@ Vajutus 3 sõrmega Vajutus 4 sõrmega Raputa seadet - ‘%s’ menüü + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-eu/01-core.xml b/AnkiDroid/src/main/res/values-eu/01-core.xml index 1f27b73870c9..ee178675a48e 100644 --- a/AnkiDroid/src/main/res/values-eu/01-core.xml +++ b/AnkiDroid/src/main/res/values-eu/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database atzitu dauden oharrak, txartelak, ohar motak, eta sortak, eta berriak sortu ere - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-eu/10-preferences.xml b/AnkiDroid/src/main/res/values-eu/10-preferences.xml index 5a0549da5b4d..68358e8124a6 100644 --- a/AnkiDroid/src/main/res/values-eu/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-eu/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-fa/01-core.xml b/AnkiDroid/src/main/res/values-fa/01-core.xml index 001a710dbe3e..9a8f2f254e13 100644 --- a/AnkiDroid/src/main/res/values-fa/01-core.xml +++ b/AnkiDroid/src/main/res/values-fa/01-core.xml @@ -127,8 +127,8 @@ بخوان و به پایگاه داده AnkiDroid پیام بده دسترسی به یادداشت‌ها، کارت‌ها، انواع یادداشت‌ها و دسته‌های موجود، و همچنین ایجاد موارد جدید - مرورگر کارت - کارت آنکی + Card browser + Anki card مجموعه‌ها نمی‌توانند ترکیب شوند.\nکدام مجموعه را می‌خواهید نگه دارید؟ انکی دروید diff --git a/AnkiDroid/src/main/res/values-fa/10-preferences.xml b/AnkiDroid/src/main/res/values-fa/10-preferences.xml index bcd3c2ab88be..d67407b5be79 100644 --- a/AnkiDroid/src/main/res/values-fa/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-fa/10-preferences.xml @@ -107,7 +107,7 @@ لمس سه انگشتی لمس چهار انگشتی لرزش دستگاه - منوی \'%s\' + ‘%s’ menu \'%s\' را در لیست به صورت عمومی فعال می‌کند پیمایش دو برابری فعال کنید اگر می‌خواهید فاصله پیمایش را در کتابخوان دیجیتالی دو برابر کنید diff --git a/AnkiDroid/src/main/res/values-fi/01-core.xml b/AnkiDroid/src/main/res/values-fi/01-core.xml index 6a58f691848b..27271f607778 100644 --- a/AnkiDroid/src/main/res/values-fi/01-core.xml +++ b/AnkiDroid/src/main/res/values-fi/01-core.xml @@ -127,8 +127,8 @@ Lue ja kirjoita AnkiDroidi-tietokantaan lupa päästä olemassaoleviin merkintöihin, kortteihin, merkintätyyppeihin ja pakkoihin, sekä luoda uusia - Korttiselain - Anki-kortti + Card browser + Anki card Kokoelmia ei voi yhdistää.\nMinkä kokoelman haluat säilyttää? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-fi/10-preferences.xml b/AnkiDroid/src/main/res/values-fi/10-preferences.xml index 2941594362d3..515cb5a0e930 100644 --- a/AnkiDroid/src/main/res/values-fi/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-fi/10-preferences.xml @@ -107,7 +107,7 @@ Kolmesorminen napautus Neljäsorminen napautus Ravista laitetta - \"%s\" -valikko + ‘%s’ menu Ota \"%s\" -valikko käyttöön koko sovelluksessa Kaksoisvieritys Kaksinkertaista vieritysaskeleen koko eReaderin kanssa diff --git a/AnkiDroid/src/main/res/values-fil/01-core.xml b/AnkiDroid/src/main/res/values-fil/01-core.xml index ad5cba0b108e..31c5ba4e5b64 100644 --- a/AnkiDroid/src/main/res/values-fil/01-core.xml +++ b/AnkiDroid/src/main/res/values-fil/01-core.xml @@ -127,8 +127,8 @@ Basahin at isulat sa database ng AnkiDroid i-access ang mga umiiral na tala, kard, mga uri ng tala, at mga deck, pati na rin ang gumawa ng mga bago - Card Browser - Anki Card + Card browser + Anki card Hindi maipagsama ang mga koleksyon.\nAlin sa mga koleksyon ang nais mong panatilihin? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-fil/10-preferences.xml b/AnkiDroid/src/main/res/values-fil/10-preferences.xml index c19998b93566..b82755a7daf4 100644 --- a/AnkiDroid/src/main/res/values-fil/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-fil/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Alugin ang device - Menu ng \'%s\' + ‘%s’ menu Ine-enable ang context manu na \'%s\' kahit saan Double scrolling I-double ang scroll gap sa eReader diff --git a/AnkiDroid/src/main/res/values-fr/01-core.xml b/AnkiDroid/src/main/res/values-fr/01-core.xml index 1a0a6809badd..796d411b92b7 100644 --- a/AnkiDroid/src/main/res/values-fr/01-core.xml +++ b/AnkiDroid/src/main/res/values-fr/01-core.xml @@ -24,7 +24,7 @@ --> - Navigateur de cartes + Card browser Paramètres Aide @@ -127,8 +127,8 @@ Lire et écrire dans la base de données d\'AnkiDroid accéder aux notes, cartes, types de notes et paquets actuels, mais aussi en créer de nouveaux - Navigateur de cartes - Carte Anki + Card browser + Anki card Les collections ne peuvent pas être combinées.\nQuelle collection voulez-vous garder ? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-fr/10-preferences.xml b/AnkiDroid/src/main/res/values-fr/10-preferences.xml index 4dd65756081d..4bff6ebc03e8 100644 --- a/AnkiDroid/src/main/res/values-fr/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-fr/10-preferences.xml @@ -107,7 +107,7 @@ Tape à trois doigts Tape à quatre doigts Secouer l\'appareil - Menu « %s » + ‘%s’ menu Active le menu contextuel « %s » globalement Défilement double Doubler la vitesse de défilement avec eReader diff --git a/AnkiDroid/src/main/res/values-fy/01-core.xml b/AnkiDroid/src/main/res/values-fy/01-core.xml index 8e1b6e3e6abf..04ed4687081d 100644 --- a/AnkiDroid/src/main/res/values-fy/01-core.xml +++ b/AnkiDroid/src/main/res/values-fy/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-fy/10-preferences.xml b/AnkiDroid/src/main/res/values-fy/10-preferences.xml index d209df765346..22ad3f8f77db 100644 --- a/AnkiDroid/src/main/res/values-fy/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-fy/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-ga/01-core.xml b/AnkiDroid/src/main/res/values-ga/01-core.xml index ef9abd271e86..3116c89b097e 100644 --- a/AnkiDroid/src/main/res/values-ga/01-core.xml +++ b/AnkiDroid/src/main/res/values-ga/01-core.xml @@ -142,8 +142,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - Brabhsálaí Cártaí - Carta Anki + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ga/10-preferences.xml b/AnkiDroid/src/main/res/values-ga/10-preferences.xml index e2d632ea3117..ffeb4b5caf2f 100644 --- a/AnkiDroid/src/main/res/values-ga/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ga/10-preferences.xml @@ -110,7 +110,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-gl/01-core.xml b/AnkiDroid/src/main/res/values-gl/01-core.xml index 3b468429888b..e94581a2ea05 100644 --- a/AnkiDroid/src/main/res/values-gl/01-core.xml +++ b/AnkiDroid/src/main/res/values-gl/01-core.xml @@ -127,8 +127,8 @@ Ler e escribir na base de datos do AnkiDroid acceder ás notas, tarxetas, tipos de notas e barallas existentes, así como crear novas - Explorador de tarxetas - Tarxeta Anki + Card browser + Anki card Non se poden combinar as coleccións.\nQue colección queres manter? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-gl/10-preferences.xml b/AnkiDroid/src/main/res/values-gl/10-preferences.xml index 9bd41b04f12d..625d43d8a8e5 100644 --- a/AnkiDroid/src/main/res/values-gl/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-gl/10-preferences.xml @@ -107,7 +107,7 @@ Toque con tres dedos Toque con catro dedos Axitar o dispositivo - Menú ‘%s’ + ‘%s’ menu Activar globalmente o menú contextual ‘%s’ Desprazamento dobre Duplicar a distancia de desprazamento co eReader diff --git a/AnkiDroid/src/main/res/values-got/01-core.xml b/AnkiDroid/src/main/res/values-got/01-core.xml index bdb61e8b6ffa..b45a36715ca1 100644 --- a/AnkiDroid/src/main/res/values-got/01-core.xml +++ b/AnkiDroid/src/main/res/values-got/01-core.xml @@ -127,8 +127,8 @@ 𐌳𐌿 𐌰𐌽𐌺𐌴𐌹𐌳𐍂𐌰𐌿𐌳 𐌳𐌰𐍄𐌰𐌱𐌿𐍃𐌰 𐌰𐌽𐌰𐌺𐌿𐌽𐌽𐌰𐌽 𐌾𐌰𐌷 𐌼𐌴𐌻𐌾𐌰𐌽 𐌰𐍄𐌲𐌰𐌲𐌲 𐌹𐌽 𐍅𐌹𐍃𐌰𐌽𐌳𐌴𐌹𐌽𐍃 𐌼𐌴𐌻𐌴𐌹𐌽𐌹𐌽𐍃, 𐌺𐌰𐍂𐍄𐍉𐍃, 𐌷𐌰𐌹𐌳𐌿𐌽𐍃 𐌼𐌴𐌻𐌴𐌹𐌽𐍉, 𐌾𐌰𐌷 𐌺𐌰𐍂𐍄𐌰𐌷𐌿𐌶𐌳𐌰, 𐌾𐌰𐌷 𐌽𐌹𐌿𐌾𐌰 𐍃𐌺𐌰𐍀𐌾𐌰𐌽 - 𐍃𐍉𐌺𐌾𐍉 𐌺𐌰𐍂𐍄𐍉 - 𐌰𐌽𐌺𐌴𐌹 𐌺𐌰𐍂𐍄𐌰 + Card browser + Anki card 𐌷𐌿𐌶𐌳𐌰 𐌽𐌹 𐌼𐌰𐌲𐌿𐌽 𐌱𐌻𐌰𐌽𐌳𐌰𐌽.\n𐍈𐌹𐌻𐌴𐌹𐌺 𐌷𐌿𐌶𐌳 𐍅𐌹𐌻𐌴𐌹𐍃 𐌲𐌰𐍆𐌰𐍃𐍄𐌰𐌽? 𐌰𐌽𐌺𐌴𐌹𐌳𐍂𐌰𐌿𐌳 diff --git a/AnkiDroid/src/main/res/values-got/10-preferences.xml b/AnkiDroid/src/main/res/values-got/10-preferences.xml index 2f597390d98f..48962545c894 100644 --- a/AnkiDroid/src/main/res/values-got/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-got/10-preferences.xml @@ -107,7 +107,7 @@ 𐍄𐌴𐌷𐍄𐍃 𐌸𐍂𐌹𐌼 𐍆𐌹𐌲𐌲𐍂𐌰𐌼 𐍄𐌴𐌷𐍄𐍃 𐍆𐌹𐌳𐍅𐍉𐍂𐌹𐌼 𐍆𐌹𐌲𐌲𐍂𐌰𐌼 Shake device - \'%s\' 𐌼𐌰𐌹𐌽𐍅 + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-gu/01-core.xml b/AnkiDroid/src/main/res/values-gu/01-core.xml index 2fe71a0ca591..f1de1c56e36a 100644 --- a/AnkiDroid/src/main/res/values-gu/01-core.xml +++ b/AnkiDroid/src/main/res/values-gu/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ડેટાબેઝ વાંચો અને લખો હાલની નોંધો, કાર્ડ્સ, નોંધના પ્રકારો અને ડેકને ઍક્સેસ કરો તેમજ નવી બનાવો - કાર્ડ બ્રાઉઝર - અંકી કાર્ડ + Card browser + Anki card સંગ્રહોને જોડી શકાતા નથી.\nતમે કયો સંગ્રહ રાખવા માંગો છો? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-gu/10-preferences.xml b/AnkiDroid/src/main/res/values-gu/10-preferences.xml index 421939412822..6d936eedf4ac 100644 --- a/AnkiDroid/src/main/res/values-gu/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-gu/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap શેક ઉપકરણ - \'%s\' મેનૂ + ‘%s’ menu વૈશ્વિક સ્તરે \'%s\' સંદર્ભ મેનૂને સક્ષમ કરે છે ડબલ સ્ક્રોલિંગ eReader વડે સ્ક્રોલ ગેપને બમણો કરો diff --git a/AnkiDroid/src/main/res/values-heb/01-core.xml b/AnkiDroid/src/main/res/values-heb/01-core.xml index 3972656f2d4d..c61192846538 100644 --- a/AnkiDroid/src/main/res/values-heb/01-core.xml +++ b/AnkiDroid/src/main/res/values-heb/01-core.xml @@ -133,8 +133,8 @@ קרא ושמור למסד הנתונים של AnkiDroid לגשת להערות, כרטיס, סוגי רשומות וחפיסות וגם ליצור חדשים - דפדפן הקלפים - כרטיס Anki + Card browser + Anki card לא ניתן לחבר את האוספים. איזה אחד לשמור? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-heb/10-preferences.xml b/AnkiDroid/src/main/res/values-heb/10-preferences.xml index c94d0b68a371..67664fd18432 100644 --- a/AnkiDroid/src/main/res/values-heb/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-heb/10-preferences.xml @@ -108,7 +108,7 @@ הקשה בשלוש אצבעות הקשה בארבע אצבעות נער את המכשיר - תפריט %s + ‘%s’ menu מאפשר את תפריט ההקשר \'%s\' גלובלית במערכת גלילה כפולה הכפל את פער הגלילה עם eReader diff --git a/AnkiDroid/src/main/res/values-hi/01-core.xml b/AnkiDroid/src/main/res/values-hi/01-core.xml index 6866cc4f6c2b..e5b801a6f53e 100644 --- a/AnkiDroid/src/main/res/values-hi/01-core.xml +++ b/AnkiDroid/src/main/res/values-hi/01-core.xml @@ -129,8 +129,8 @@ Back - बैक पढ़ें और AnkiDroid डेटाबेस के लिए लिखें मौजूदा नोट्स, कार्ड, नोट प्रकार, और डेक तक पहुंचें, साथ ही नए बनाएं - कार्ड ब्राउज़र - Anki कार्ड + Card browser + Anki card संग्रहों को जोड़ा नहीं जा सकता.\nआप कौन सा संग्रह रखना चाहते हैं? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-hi/10-preferences.xml b/AnkiDroid/src/main/res/values-hi/10-preferences.xml index eb145cdb27fa..09e6d4d88b2a 100644 --- a/AnkiDroid/src/main/res/values-hi/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-hi/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap डिवाइस हिलाओ - ‘%s’ मेन्यू + ‘%s’ menu विश्व स्तर पर \'%s\' संदर्भ मेनू को सक्षम करता है डबल स्क्रॉल ई-रीडर के साथ स्क्रॉल गैप डबल diff --git a/AnkiDroid/src/main/res/values-hr/01-core.xml b/AnkiDroid/src/main/res/values-hr/01-core.xml index 09342520969a..958fef89c751 100644 --- a/AnkiDroid/src/main/res/values-hr/01-core.xml +++ b/AnkiDroid/src/main/res/values-hr/01-core.xml @@ -132,8 +132,8 @@ Čitanje i pisanje u AnkiDroid bazu podataka Pristupi postojećim bilješkama, karticama, tipovima kartica i špilovima, te kreiranju novih - Preglednik karata - Anki kartica + Card browser + Anki card Kolekcije ne mogu biti kombinirane.\nKoju kolekciju želite zadržati? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-hr/10-preferences.xml b/AnkiDroid/src/main/res/values-hr/10-preferences.xml index 231398edc38d..3eccf418ebda 100644 --- a/AnkiDroid/src/main/res/values-hr/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-hr/10-preferences.xml @@ -108,7 +108,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-hu/01-core.xml b/AnkiDroid/src/main/res/values-hu/01-core.xml index 8e81ce1efd23..402b9de862b2 100644 --- a/AnkiDroid/src/main/res/values-hu/01-core.xml +++ b/AnkiDroid/src/main/res/values-hu/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid adatbázist olvasás/írás meglévő jegyzetek, kártyák, jegyzetfajták és paklik elérése, valamint újak létrehozása - Kártya böngésző - Anki Kártya + Card browser + Anki card A gyűjtemények nem kombinálhatók.\nMelyik gyűjteményt akarod megtartani? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-hu/10-preferences.xml b/AnkiDroid/src/main/res/values-hu/10-preferences.xml index 5b65e80c10b4..4794a8f3dd8b 100644 --- a/AnkiDroid/src/main/res/values-hu/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-hu/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Készülék megrázása - \'%s\' Menü + ‘%s’ menu Engedélyezi a \'%s\' kontextus menüt globálisan Dupla görgetés Görgess a kétszeres méretű e-Olvasó méretre diff --git a/AnkiDroid/src/main/res/values-hy/01-core.xml b/AnkiDroid/src/main/res/values-hy/01-core.xml index b9a05da16a92..4ed7e55fa5b1 100644 --- a/AnkiDroid/src/main/res/values-hy/01-core.xml +++ b/AnkiDroid/src/main/res/values-hy/01-core.xml @@ -127,8 +127,8 @@ Կարդալ և գրել AnkiDroid-ի շտեմարանի մեջ մատչել առկա գրառումները, քարտերը, գրառումների տեսակները և կապուկները, ինչպես նաև նորերը ստեղծելը - Զննիչ - Anki քարտ + Card browser + Anki card Հավաքածուները չեն կարող իրար միանալ։ Ո՞ր հավաքածուն եք ուզում պահպանել։ AnkiDroid diff --git a/AnkiDroid/src/main/res/values-hy/10-preferences.xml b/AnkiDroid/src/main/res/values-hy/10-preferences.xml index f73c93e9a820..3d40685b0f1b 100644 --- a/AnkiDroid/src/main/res/values-hy/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-hy/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Թափահարել սարքը - «%s» մենյու + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-ind/01-core.xml b/AnkiDroid/src/main/res/values-ind/01-core.xml index 133fa054d76e..0e76c02f2995 100644 --- a/AnkiDroid/src/main/res/values-ind/01-core.xml +++ b/AnkiDroid/src/main/res/values-ind/01-core.xml @@ -122,8 +122,8 @@ Baca dan tulis ke basisdata AnkiDroid mengakses catatan yang ada, kartu, tipe catatan, dan dek, serta membuat yang baru - Peramban Kartu - Kartu Anki + Card browser + Anki card Koleksi tidak dapat digabungkan. \nBagian mana yang ingin Anda pertahankan? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ind/10-preferences.xml b/AnkiDroid/src/main/res/values-ind/10-preferences.xml index 666b41e26213..fb004e2dc205 100644 --- a/AnkiDroid/src/main/res/values-ind/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ind/10-preferences.xml @@ -106,7 +106,7 @@ Three-finger tap Four-finger tap Goyangkan perangkat - Menu ‘%s’ + ‘%s’ menu Mengaktifkan konteks menu ‘%s’ secara global Guliran ganda Gandakan jarak gulir dengan pembaca elektronik diff --git a/AnkiDroid/src/main/res/values-it/01-core.xml b/AnkiDroid/src/main/res/values-it/01-core.xml index 984d08d1e9a7..56cb2b415f8f 100644 --- a/AnkiDroid/src/main/res/values-it/01-core.xml +++ b/AnkiDroid/src/main/res/values-it/01-core.xml @@ -127,8 +127,8 @@ Leggere e scrivere nel database di AnkiDroid accedere a note esistenti, carte, modelli e mazzi, nonché crearne di nuovi - Browser delle carte - Carta Anki + Card browser + Anki card Le collezioni non possono essere unite.\nQuale collezione vuoi mantenere? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-it/10-preferences.xml b/AnkiDroid/src/main/res/values-it/10-preferences.xml index 160d770d1f6e..b48d44193f56 100644 --- a/AnkiDroid/src/main/res/values-it/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-it/10-preferences.xml @@ -107,7 +107,7 @@ Tocco a tre dita Tocco a quattro dita Agita il dispositivo - Menù «%s» + ‘%s’ menu Abilita il menù contestuale «%s» globalmente Doppio scorrimento Raddoppia il divario di scorrimento con eReader diff --git a/AnkiDroid/src/main/res/values-iw/01-core.xml b/AnkiDroid/src/main/res/values-iw/01-core.xml index 3972656f2d4d..c61192846538 100644 --- a/AnkiDroid/src/main/res/values-iw/01-core.xml +++ b/AnkiDroid/src/main/res/values-iw/01-core.xml @@ -133,8 +133,8 @@ קרא ושמור למסד הנתונים של AnkiDroid לגשת להערות, כרטיס, סוגי רשומות וחפיסות וגם ליצור חדשים - דפדפן הקלפים - כרטיס Anki + Card browser + Anki card לא ניתן לחבר את האוספים. איזה אחד לשמור? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-iw/10-preferences.xml b/AnkiDroid/src/main/res/values-iw/10-preferences.xml index c94d0b68a371..67664fd18432 100644 --- a/AnkiDroid/src/main/res/values-iw/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-iw/10-preferences.xml @@ -108,7 +108,7 @@ הקשה בשלוש אצבעות הקשה בארבע אצבעות נער את המכשיר - תפריט %s + ‘%s’ menu מאפשר את תפריט ההקשר \'%s\' גלובלית במערכת גלילה כפולה הכפל את פער הגלילה עם eReader diff --git a/AnkiDroid/src/main/res/values-ja/01-core.xml b/AnkiDroid/src/main/res/values-ja/01-core.xml index b957a5678b4c..04962518b3ab 100644 --- a/AnkiDroid/src/main/res/values-ja/01-core.xml +++ b/AnkiDroid/src/main/res/values-ja/01-core.xml @@ -122,8 +122,8 @@ AnkiDroidデータベースの読み取りと書き込み ノート、カード、ノートタイプ、デッキへのアクセスや、それらの新規作成を行います。 - AnkiDroid検索 - Ankiカード + Card browser + Anki card コレクションを統合できません。\nどちらのコレクションを保持しますか? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ja/02-strings.xml b/AnkiDroid/src/main/res/values-ja/02-strings.xml index 6564c8bb5a89..02790e213ad7 100644 --- a/AnkiDroid/src/main/res/values-ja/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ja/02-strings.xml @@ -203,7 +203,7 @@ AnkiDroid JS API のバージョンが無効です。開発者の %s に連絡するか、Wikiで確認してみてください。 AnkiDroid JS API が更新されました。開発者の %s に連絡するか、Wikiで確認してみてください。 ビュー - (Error code: %d) + (エラーコード: %d) クリップボードにコピーしました デバッグ情報をクリップボードにコピー中にエラーが発生しました @@ -297,7 +297,7 @@ 負担は小さく 成果は大きく Ankiは、記憶を定着させ強化するのに適したスケジュールでカードを表示する間隔反復学習システムです。AnkiDroidでAnkiの効率的な学習をお楽しみください - Get started + 始める AnkiWebから同期 AnkiDroidがまだ起動していません。AnkiDroidを開いてから、もう一度やり直してください @@ -346,22 +346,22 @@ ノート編集画面 ノートタイプを選択 タグを編集 - Card template editor + カードテンプレートエディタ 表面テンプレートを編集 裏面テンプレートを編集 スタイルを編集 テンプレートをMarkdowin形式でコピー カードブラウザ用の表示設定を編集 ユーザーアクション %s はこのノートタイプに設定されていません。設定してください - This card has features that are not yet supported by the new study screen. + このカードには、新しい学習画面ではまだサポートされていない機能があります。 アクセスを回復する方法の詳細を %s で確認するか、設定画面に移動してコレクションフォルダを更新してください。 スケジューラをアップグレードする必要があります , - Day - Date range + 日 + 期間 Already in progress プロファイルを切り替える diff --git a/AnkiDroid/src/main/res/values-ja/03-dialogs.xml b/AnkiDroid/src/main/res/values-ja/03-dialogs.xml index 2e6fe2000aa3..b8e69dd2dce8 100644 --- a/AnkiDroid/src/main/res/values-ja/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ja/03-dialogs.xml @@ -81,7 +81,7 @@ 証明書の解析エラー 証明書が更新されました - This may take a long time with large media collections. Proceed? + メディア(画像・音声・動画)ファイルが多いと、チェックに長い時間がかかる場合があります。続行しますか? メディアをチェックしています… タグを追加しています… Tagging results @@ -254,7 +254,7 @@ From To - Profile name - Profile name can’t be empty - Profile name must be %1$d characters or fewer + プロファイル名 + プロファイル名を空にすることはできません + プロファイル名は最大 %1$d 文字までです。 diff --git a/AnkiDroid/src/main/res/values-ja/07-cardbrowser.xml b/AnkiDroid/src/main/res/values-ja/07-cardbrowser.xml index c48b1a4ea63c..2f2072c001dc 100644 --- a/AnkiDroid/src/main/res/values-ja/07-cardbrowser.xml +++ b/AnkiDroid/src/main/res/values-ja/07-cardbrowser.xml @@ -51,7 +51,7 @@ 表示順を変更 検索 検索 - 表示順の選択 + 表示順を選択 タグ リストの列を管理 利用可能な列がありません @@ -87,8 +87,8 @@ See more See less - No sorting - Faster + ソート(並び替え)しない + 並べ替え処理をしない分、表示されるまでの時間が短くなります Sort by %1$s A–Z Z–A @@ -107,7 +107,7 @@ 昇順 降順 Selected - Unavailable + 使用不可 Card content can\'t be sorted. Use Sort Field Only available in cards mode diff --git a/AnkiDroid/src/main/res/values-ja/10-preferences.xml b/AnkiDroid/src/main/res/values-ja/10-preferences.xml index 1c4de653d39c..7028584cf355 100644 --- a/AnkiDroid/src/main/res/values-ja/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ja/10-preferences.xml @@ -106,7 +106,7 @@ 3点同時タップ 4点同時タップ デバイスを振る - 「%s」メニュー + ‘%s’ menu AnkiDroidおよび他のアプリ(ウェブブラウザなど)で、文字列を選択した際に表示されるコンテキストメニューに「%s」という項目を追加します ダブルスクロール eReader とのスクロールのギャップを 2 倍にする場合、これを有効にします diff --git a/AnkiDroid/src/main/res/values-ka/01-core.xml b/AnkiDroid/src/main/res/values-ka/01-core.xml index 713b67887a87..bbda1b941949 100644 --- a/AnkiDroid/src/main/res/values-ka/01-core.xml +++ b/AnkiDroid/src/main/res/values-ka/01-core.xml @@ -127,8 +127,8 @@ წაკითხვა და ჩაწერა AnkiDroid-ის მონაცემთა ბაზაში წვდომა არსებულ შენიშვნებთან, ბარათებთან, შენიშვნის ტიპებთან და დასტებთან, ასევე ახალი ბარათების შექმნასთან - ბარათების საძიებო - Anki-ბარათი + Card browser + Anki card კოლექციები ვერ შეერთდება.\nრომელი კოლექციის დატოვება გსურთ? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ka/10-preferences.xml b/AnkiDroid/src/main/res/values-ka/10-preferences.xml index 58564437b305..79aa176bdcfa 100644 --- a/AnkiDroid/src/main/res/values-ka/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ka/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap მოწყობილობის შენჯღრევა - „%s“-მენიუ + ‘%s’ menu გლობალურად ამატებს „%s“ ღილაკს კონტექსტურ მენიუში (მენიუ, რომელიც ტექსტის მონიშვნისას ჩნდება) ორმაგი ჩამოყოლა ელ-წამკითხველებში ეკრანის აწევ-ჩამოწევის დაშორების გაორმაგება diff --git a/AnkiDroid/src/main/res/values-kk/01-core.xml b/AnkiDroid/src/main/res/values-kk/01-core.xml index 96b686315241..4efb7c9c0114 100644 --- a/AnkiDroid/src/main/res/values-kk/01-core.xml +++ b/AnkiDroid/src/main/res/values-kk/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-kk/10-preferences.xml b/AnkiDroid/src/main/res/values-kk/10-preferences.xml index 5f071239db6a..5fba3e8c30c5 100644 --- a/AnkiDroid/src/main/res/values-kk/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-kk/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-km/01-core.xml b/AnkiDroid/src/main/res/values-km/01-core.xml index bcbad464e822..aea47a13dd4d 100644 --- a/AnkiDroid/src/main/res/values-km/01-core.xml +++ b/AnkiDroid/src/main/res/values-km/01-core.xml @@ -122,8 +122,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - ការរកមើលកាត - កាត​ Anki + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-km/10-preferences.xml b/AnkiDroid/src/main/res/values-km/10-preferences.xml index 12c69cb526b2..4e7c67606cce 100644 --- a/AnkiDroid/src/main/res/values-km/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-km/10-preferences.xml @@ -106,7 +106,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-kn/01-core.xml b/AnkiDroid/src/main/res/values-kn/01-core.xml index e8029a5419f1..871cf090ca69 100644 --- a/AnkiDroid/src/main/res/values-kn/01-core.xml +++ b/AnkiDroid/src/main/res/values-kn/01-core.xml @@ -127,8 +127,8 @@ ಆಂಕಿಡ್ರಾಯ್ಡ್ ಡೇಟಾಬೇಸ್‌ಗೆ ಓದಿ ಮತ್ತು ಬರೆಯಿರಿ ಅಸ್ತಿತ್ವದಲ್ಲಿರುವ ಟಿಪ್ಪಣಿಗಳು, ಕಾರ್ಡ್‌ಗಳು, ಟಿಪ್ಪಣಿ ಪ್ರಕಾರಗಳು ಮತ್ತು ಡೆಕ್‌ಗಳನ್ನು ಪ್ರವೇಶಿಸಿ, ಹಾಗೆಯೇ ಹೊಸದನ್ನು ರಚಿಸಿ - ಕಾರ್ಡ್ ಬ್ರೌಸರ್ - ಅಂಕಿ ಕಾರ್ಡ್ + Card browser + Anki card ಸಂಗ್ರಹಣೆಗಳನ್ನು ಸಂಯೋಜಿಸಲಾಗುವುದಿಲ್ಲ. \nನೀವು ಯಾವ ಸಂಗ್ರಹವನ್ನು ಇಡಲು ಬಯಸುತ್ತೀರಿ? ಆಂಕಿಡ್ರಾಯ್ಡ್ diff --git a/AnkiDroid/src/main/res/values-kn/10-preferences.xml b/AnkiDroid/src/main/res/values-kn/10-preferences.xml index 36fd94e59c48..32ffd50ee1e9 100644 --- a/AnkiDroid/src/main/res/values-kn/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-kn/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap ಸಾಧನವನ್ನು ಅಲ್ಲಾಡಿಸಿ - \'%s\' ಮೆನು + ‘%s’ menu ಜಾಗತಿಕವಾಗಿ \'%s\' ಸಂದರ್ಭ ಮೆನುವನ್ನು ಸಕ್ರಿಯಗೊಳಿಸುತ್ತದೆ ಡಬಲ್ ಸ್ಕ್ರೋಲಿಂಗ್ eReader ನೊಂದಿಗೆ ಸ್ಕ್ರಾಲ್ ದೂರವನ್ನು ದ್ವಿಗುಣಗೊಳಿಸಿ diff --git a/AnkiDroid/src/main/res/values-ko/01-core.xml b/AnkiDroid/src/main/res/values-ko/01-core.xml index f407aaa4b21c..fb42f2076d4d 100644 --- a/AnkiDroid/src/main/res/values-ko/01-core.xml +++ b/AnkiDroid/src/main/res/values-ko/01-core.xml @@ -122,8 +122,8 @@ AnkiDroid 데이터베이스 읽고 쓰기 기존의 노트, 카드, 노트 유형, 덱에 접근하여 새로운 항목 생성하기 - 카드 탐색기 - Anki 카드 + Card browser + Anki card 컬렉션을 합칠 수 없습니다.\n어떤 컬렉션을 유지하시겠습니까? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ko/03-dialogs.xml b/AnkiDroid/src/main/res/values-ko/03-dialogs.xml index d6cbab12757b..3ac47a4a9dae 100644 --- a/AnkiDroid/src/main/res/values-ko/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ko/03-dialogs.xml @@ -117,13 +117,13 @@ Incompatible Database Version The database is a more advanced version than this version of AnkiDroid can work with. Upgrade AnkiDroid or downgrade the database to open it\n\nSupported version: %1$d\nDatabase version: %2$d\n\nThe following restore options will overwrite your current collection, possibly with a compatible database version: - Background image applied - Background image removed + 배경 이미지 적용 완료 + 배경 이미지 제거 완료 No image selected Error selecting image. Please see manual. %s Error deleting image - Failed to apply background image %s - Deck Picker background too large + 배경 이미지 적용에 실패했습니다 %s + 이미지 용량이 너무 큽니다 최대 이미지 크기는 %d MB입니다. 사진 해상도 너무 큼 (%1$d × %2$d) diff --git a/AnkiDroid/src/main/res/values-ko/10-preferences.xml b/AnkiDroid/src/main/res/values-ko/10-preferences.xml index 17aceb42c464..e23b9b132b70 100644 --- a/AnkiDroid/src/main/res/values-ko/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ko/10-preferences.xml @@ -106,7 +106,7 @@ 세 손가락으로 탭 네 손가락으로 탭 기기 흔들기 - \"%s\" 메뉴 + ‘%s’ menu 시스템 전역에서 글자를 길게 눌렀을 때 \'%s\' 컨텍스트 메뉴를 보입니다. 이중 스크롤 eReader 사용시 스크롤 간격 두배로 늘리기 diff --git a/AnkiDroid/src/main/res/values-ku/01-core.xml b/AnkiDroid/src/main/res/values-ku/01-core.xml index 8181710d1cb0..0594d1d398c5 100644 --- a/AnkiDroid/src/main/res/values-ku/01-core.xml +++ b/AnkiDroid/src/main/res/values-ku/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ku/10-preferences.xml b/AnkiDroid/src/main/res/values-ku/10-preferences.xml index 4b7dfc4d5309..3df74e2d5e26 100644 --- a/AnkiDroid/src/main/res/values-ku/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ku/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-ky/01-core.xml b/AnkiDroid/src/main/res/values-ky/01-core.xml index 174ddb9f2c52..ad6d4b56d62f 100644 --- a/AnkiDroid/src/main/res/values-ky/01-core.xml +++ b/AnkiDroid/src/main/res/values-ky/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ky/10-preferences.xml b/AnkiDroid/src/main/res/values-ky/10-preferences.xml index 5f071239db6a..5fba3e8c30c5 100644 --- a/AnkiDroid/src/main/res/values-ky/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ky/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-lt/01-core.xml b/AnkiDroid/src/main/res/values-lt/01-core.xml index 58b257fad15b..35b5ffa8d42c 100644 --- a/AnkiDroid/src/main/res/values-lt/01-core.xml +++ b/AnkiDroid/src/main/res/values-lt/01-core.xml @@ -137,8 +137,8 @@ Skaitykite ir rašykite į „AnkiDroid“ duomenų bazę prisijunkite prie turimų užrašų, kortelių, užrašų tipų ir rinkinių bei kurkite naujus - Kortelių naršyklė - Anki kortelė + Card browser + Anki card Kolekcijų negalima sujungti.\nKurią kolekciją norite pasilikti? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-lt/10-preferences.xml b/AnkiDroid/src/main/res/values-lt/10-preferences.xml index 35bc355befed..8b5aa9a20c6f 100644 --- a/AnkiDroid/src/main/res/values-lt/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-lt/10-preferences.xml @@ -109,7 +109,7 @@ Three-finger tap Four-finger tap Pakratykite prietaisą - ‘%s’ Meniu + ‘%s’ menu Visuotinai įjungiamas kontekstinis meniu \'%s\' Dvigubas slinkimas Padvigubinkite slinkimo tarpą naudodami el. knygų skaityklę diff --git a/AnkiDroid/src/main/res/values-lv/01-core.xml b/AnkiDroid/src/main/res/values-lv/01-core.xml index 8828b21461ed..62f47addf708 100644 --- a/AnkiDroid/src/main/res/values-lv/01-core.xml +++ b/AnkiDroid/src/main/res/values-lv/01-core.xml @@ -132,8 +132,8 @@ Lasīt un rakstīt AnkiDroid datubāzē Piekļūt esošajām piezīmēm, kartītēm, piezīmju veidiem un kavām, kā arī izveidot jaunas - Kartīšu pārlūks - Anki kartīte + Card browser + Anki card Krājumus nevar apvienot.\nKuru krājumu paturēt? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-lv/10-preferences.xml b/AnkiDroid/src/main/res/values-lv/10-preferences.xml index 08e32a26715a..04bfb183953f 100644 --- a/AnkiDroid/src/main/res/values-lv/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-lv/10-preferences.xml @@ -108,7 +108,7 @@ Three-finger tap Four-finger tap Kratīt ierīci - \"%s\" izvēlne + ‘%s’ menu Iespējo \"%s\" konteksta izvēlni Divkārša ritināšana Divkāršot ritināšanas attālumu ar e-lasītāju diff --git a/AnkiDroid/src/main/res/values-mk/01-core.xml b/AnkiDroid/src/main/res/values-mk/01-core.xml index 9d623e278e1b..856a63dd1b1b 100644 --- a/AnkiDroid/src/main/res/values-mk/01-core.xml +++ b/AnkiDroid/src/main/res/values-mk/01-core.xml @@ -127,8 +127,8 @@ Читајте и пишувајте на базата на податоци на AnkiDroid да пристапите до постоечките белешки, картички, типови белешки и decks, како и креирање нови - Пребарувач на карти - Анки карта + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-mk/10-preferences.xml b/AnkiDroid/src/main/res/values-mk/10-preferences.xml index 8cdcb29ed6d5..c5be5cd26dbb 100644 --- a/AnkiDroid/src/main/res/values-mk/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-mk/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-ml/01-core.xml b/AnkiDroid/src/main/res/values-ml/01-core.xml index f65450febe6e..776ad3ab1015 100644 --- a/AnkiDroid/src/main/res/values-ml/01-core.xml +++ b/AnkiDroid/src/main/res/values-ml/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ഡാറ്റാബേസ് വായിച്ച് എഴുതുക നിലവിലുള്ള കുറിപ്പുകൾ, കാർഡുകൾ, കുറിപ്പ് തരങ്ങൾ, ഡെക്കുകൾ എന്നിവ ആക്സസ് ചെയ്യുന്നതിനൊപ്പം പുതിയവ സൃഷ്ടിക്കുക - കാർഡ് ബ്രൗസർ - Anki Card + Card browser + Anki card ശേഖരങ്ങൾ സംയോജിപ്പിക്കാൻ കഴിയില്ല.\nഏത് ശേഖരമാണ് നിങ്ങൾ സൂക്ഷിക്കാൻ ആഗ്രഹിക്കുന്നത്? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ml/10-preferences.xml b/AnkiDroid/src/main/res/values-ml/10-preferences.xml index 11f4eadfd673..05b7dc05beda 100644 --- a/AnkiDroid/src/main/res/values-ml/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ml/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap ഉപകരണം കുലുക്കുക - ‘%s’ മെനു + ‘%s’ menu ആഗോളതലത്തിൽ ‘%s’ സന്ദർഭ മെനു പ്രവർത്തനക്ഷമമാക്കുന്നു ഇരട്ട സ്ക്രോളിംഗ് eReader ഉപയോഗിച്ച് സ്ക്രോൾ വിടവ് ഇരട്ടിയാക്കുക diff --git a/AnkiDroid/src/main/res/values-mn/01-core.xml b/AnkiDroid/src/main/res/values-mn/01-core.xml index 6c5a80b6f034..c2ec5e420db0 100644 --- a/AnkiDroid/src/main/res/values-mn/01-core.xml +++ b/AnkiDroid/src/main/res/values-mn/01-core.xml @@ -127,8 +127,8 @@ Read and write to the AnkiDroid database оршин буй тэмдэглэлүүд, картууд, тэмдэглэлүүдийн загварууд, болон багцуудыг ашиглах мөн шинийг бүтээх - Карт хайгч - Анки карт + Card browser + Anki card Таны том цуглуулгуудыг нэгтгэх боломжгүй байна.\nТа аль том цуглуулгийг нь хадгалж үлдэх вэ? АнкиДройд diff --git a/AnkiDroid/src/main/res/values-mn/10-preferences.xml b/AnkiDroid/src/main/res/values-mn/10-preferences.xml index 36916e1e06da..1733669553e7 100644 --- a/AnkiDroid/src/main/res/values-mn/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-mn/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-mr/01-core.xml b/AnkiDroid/src/main/res/values-mr/01-core.xml index 6969f91b1954..f1c4411140f3 100644 --- a/AnkiDroid/src/main/res/values-mr/01-core.xml +++ b/AnkiDroid/src/main/res/values-mr/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid डेटाबेस वाचा आणि लिहा विद्यमान नोट्स, कार्डे, नोट्स प्रकार आणि डेकमध्ये प्रवेश करा तसेच नवीन तयार करा - कार्ड ब्राउझर - अंकी कार्ड + Card browser + Anki card संग्रह एकत्रित करू शकत नाही. कुठला संग्रह तुम्हाला ठेवायला आवडेल ? एन्कीड्रॉंड diff --git a/AnkiDroid/src/main/res/values-mr/10-preferences.xml b/AnkiDroid/src/main/res/values-mr/10-preferences.xml index ae33dc8d381b..44e429a52e37 100644 --- a/AnkiDroid/src/main/res/values-mr/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-mr/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap शेक डिव्हाइस - ‘%s’ मेनू + ‘%s’ menu जागतिक स्तरावर ‘%s’ संदर्भ मेनू सक्षम करते डबल स्क्रोलिंग EReader सह स्क्रोल अंतर दुप्पट करा diff --git a/AnkiDroid/src/main/res/values-ms/01-core.xml b/AnkiDroid/src/main/res/values-ms/01-core.xml index 337f30f5d9cc..b7a645d2e2b0 100644 --- a/AnkiDroid/src/main/res/values-ms/01-core.xml +++ b/AnkiDroid/src/main/res/values-ms/01-core.xml @@ -122,8 +122,8 @@ Baca dan tulis ke pangkalan data AnkiDroid mencapai nota, kad, jenis nota dan dek sedia ada serta cipta yang baharu - Pencari Kad - Kad Anki + Card browser + Anki card Koleksi tidak boleh digabungkan .\nKoleksi mana yang anda ingin simpan? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ms/10-preferences.xml b/AnkiDroid/src/main/res/values-ms/10-preferences.xml index b59a8046e78f..1d6cd7e1677d 100644 --- a/AnkiDroid/src/main/res/values-ms/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ms/10-preferences.xml @@ -106,7 +106,7 @@ Three-finger tap Four-finger tap Shake device - Menu ‘%s’ + ‘%s’ menu Dayakan menu konteks ‘%s’ secara global Tatal dua kali Jarak tatal ganda dua untuk e-pembaca diff --git a/AnkiDroid/src/main/res/values-my/01-core.xml b/AnkiDroid/src/main/res/values-my/01-core.xml index 7dec2fdf0d79..c812d0efc771 100644 --- a/AnkiDroid/src/main/res/values-my/01-core.xml +++ b/AnkiDroid/src/main/res/values-my/01-core.xml @@ -124,8 +124,8 @@ Read and write to the AnkiDroid database access existing notes, cards, note types, and decks, as well as create new ones - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-my/10-preferences.xml b/AnkiDroid/src/main/res/values-my/10-preferences.xml index a2884a16f8d0..b42c976391cb 100644 --- a/AnkiDroid/src/main/res/values-my/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-my/10-preferences.xml @@ -106,7 +106,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-nl/01-core.xml b/AnkiDroid/src/main/res/values-nl/01-core.xml index 844ab75f619a..aefe6d3bac8c 100644 --- a/AnkiDroid/src/main/res/values-nl/01-core.xml +++ b/AnkiDroid/src/main/res/values-nl/01-core.xml @@ -127,8 +127,8 @@ Lees en schrijf naar de AnkiDroid database toegang tot bestaande notities, kaarten, notitietypen en sets, eventueel nieuwe aanmaken - Kaartenverkenner - Ankikaart + Card browser + Anki card De verzamelingen kunnen niet worden gecombineerd.\nWelke verzameling wil je behouden? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-nn/01-core.xml b/AnkiDroid/src/main/res/values-nn/01-core.xml index 17a106d80985..02181b3fc065 100644 --- a/AnkiDroid/src/main/res/values-nn/01-core.xml +++ b/AnkiDroid/src/main/res/values-nn/01-core.xml @@ -127,8 +127,8 @@ Les og skriv til AnkiDroid-databasen tilgang til eksisterande notat, kort, notattypar og kortstokkar, og dessutan løyve til å laga nye - Kortutforskar - Anki kort + Card browser + Anki card Samlingane kan ikkje verta kombinerte.\nKva samling vil du halda på? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-nn/10-preferences.xml b/AnkiDroid/src/main/res/values-nn/10-preferences.xml index 23f50760ce97..60ceccba6f61 100644 --- a/AnkiDroid/src/main/res/values-nn/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-nn/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Meny + ‘%s’ menu Enables the ‘%s’ context menu globally Dobbelskrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-no/01-core.xml b/AnkiDroid/src/main/res/values-no/01-core.xml index c74a1ed8fcd0..62c78781a5cb 100644 --- a/AnkiDroid/src/main/res/values-no/01-core.xml +++ b/AnkiDroid/src/main/res/values-no/01-core.xml @@ -127,8 +127,8 @@ Les og skriv til AnkiDroid databasen. tilgang til eksisterende notater, kort, notattyper og dekker, i tillegg til å opprette nye - Kortoversikt - Anki kort + Card browser + Anki card Samlingene kan ikke kombineres.\nHvilken samling vil du beholde? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-no/10-preferences.xml b/AnkiDroid/src/main/res/values-no/10-preferences.xml index 92ae43bdfb01..93d2da0e6e95 100644 --- a/AnkiDroid/src/main/res/values-no/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-no/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Meny + ‘%s’ menu Enables the ‘%s’ context menu globally Dobbelskrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-or/01-core.xml b/AnkiDroid/src/main/res/values-or/01-core.xml index e8a05cfa326e..22d0c00d3eac 100644 --- a/AnkiDroid/src/main/res/values-or/01-core.xml +++ b/AnkiDroid/src/main/res/values-or/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ଡାଟାବେସ୍ କୁ ପଢ଼ିବା ଓ ଲେଖିବା ଵିଦ୍ୟମାନ ନୋଟ୍, ପତ୍ର, ନୋଟ୍ ପ୍ରକାର ଓ କେରୀ ଅଧ୍ୟୟନ କରିବା ଵ୍ୟତୀତ ନୂଆରେ ସୃଷ୍ଟି କରିବା - ପତ୍ର ବ୍ରାଉଜର୍ - Anki ପତ୍ର + Card browser + Anki card ସଂଗ୍ରହଗୁଡ଼ିକ ମିଶ୍ରଣ ହୋଇପାରିବ ନାହିଁ।\nଆପଣ କେଉଁ ସଂଗ୍ରହ ରଖିବାକୁ ଚାହାଁନ୍ତି? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-or/10-preferences.xml b/AnkiDroid/src/main/res/values-or/10-preferences.xml index 86d1a25caeb2..319cdbb002f0 100644 --- a/AnkiDroid/src/main/res/values-or/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-or/10-preferences.xml @@ -107,7 +107,7 @@ ତିନି-ଆଙ୍ଗୁଳି ସ୍ପର୍ଶ ଚାରି-ଆଙ୍ଗୁଳି ସ୍ପର୍ଶ ଡିଵାଇସ୍ ଝାଙ୍କିଲେ - ‘%s’ ମେନୁ + ‘%s’ menu ସବୁ ଜାଗାରେ ‘%s’ ପ୍ରସଙ୍ଗ ମେନୁକୁ ସକ୍ଷମ କରିଥାଏ ଦ୍ୱିଗୁଣ ସ୍କ୍ରୋଲିଂ eReaderରେ ସ୍କ୍ରୋଲ୍ ଫାଙ୍କକୁ ଦୁଇଗୁଣ କରନ୍ତୁ diff --git a/AnkiDroid/src/main/res/values-pa/01-core.xml b/AnkiDroid/src/main/res/values-pa/01-core.xml index 186b2bdd5a80..5acb936f102b 100644 --- a/AnkiDroid/src/main/res/values-pa/01-core.xml +++ b/AnkiDroid/src/main/res/values-pa/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ਡੇਟਾਬੇਸ ਨੂੰ ਪੜ੍ਹੋ ਅਤੇ ਲਿਖੋ ਮੌਜੂਦਾ ਨੋਟਸ, ਕਾਰਡ, ਨੋਟ ਕਿਸਮਾਂ ਅਤੇ ਡੇਕ ਤੱਕ ਪਹੁੰਚ ਕਰੋ, ਨਾਲ ਹੀ ਨਵੇਂ ਬਣਾਓ - ਕਾਰਡ ਬ੍ਰਾਊਜ਼ਰ - ਅੰਕੀ ਕਾਰਡ + Card browser + Anki card ਸੰਗ੍ਰਹਿ ਸ਼ਾਮਲ ਨਹੀਂ ਕੀਤੇ ਜਾ ਸਕਦੇ ਹਨ।\nਤੁਸੀਂ ਕਿਹੜਾ ਸੰਗ੍ਰਹਿ ਰੱਖਣਾ ਚਾਹੋਗੇ? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-pa/10-preferences.xml b/AnkiDroid/src/main/res/values-pa/10-preferences.xml index 2366b54f9b04..a437cdafa0dd 100644 --- a/AnkiDroid/src/main/res/values-pa/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-pa/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap ਸ਼ੇਕ ਜੰਤਰ - \'%s\' ਮੀਨੂ + ‘%s’ menu ਵਿਸ਼ਵ ਪੱਧਰ \'ਤੇ \'%s\' ਸੰਦਰਭ ਮੀਨੂ ਨੂੰ ਸਮਰੱਥ ਬਣਾਉਂਦਾ ਹੈ ਡਬਲ ਸਕ੍ਰੋਲਿੰਗ eReader ਨਾਲ ਸਕ੍ਰੋਲ ਗੈਪ ਨੂੰ ਦੁੱਗਣਾ ਕਰੋ diff --git a/AnkiDroid/src/main/res/values-pl/01-core.xml b/AnkiDroid/src/main/res/values-pl/01-core.xml index 9f6b982b1e91..d51465da6021 100644 --- a/AnkiDroid/src/main/res/values-pl/01-core.xml +++ b/AnkiDroid/src/main/res/values-pl/01-core.xml @@ -137,8 +137,8 @@ Odczyt i zapis do bazy danych AnkiDroid dostęp do istniejących notatek, kart, typów notatek i talii, a także tworzenia nowych - Przeglądarka kart - Karta Anki + Card browser + Anki card Kolekcje nie mogą zostać połączone.\nKtórą kolekcję chcesz zachować? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-pl/10-preferences.xml b/AnkiDroid/src/main/res/values-pl/10-preferences.xml index e4bc38533c8d..93e8eafbc31b 100644 --- a/AnkiDroid/src/main/res/values-pl/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-pl/10-preferences.xml @@ -109,7 +109,7 @@ Pacnięcie trzema palcami Pacnięcie czterema palcami Potrząśnij urządzeniem - Menu „%s” + ‘%s’ menu Włącza menu kontekstowe „%s”globalnie Podwójne przewijanie Podwójny skok suwaka na eReaderze @@ -269,8 +269,8 @@ Przełącz auto-postęp Enable auto advance Disable auto advance - Enable voice recording - Disable voice recording + Włącz nagrywanie dźwięku + Wyłącz nagrywanie dźwięku Wybierz stronę karty Pytanie diff --git a/AnkiDroid/src/main/res/values-pt-rBR/01-core.xml b/AnkiDroid/src/main/res/values-pt-rBR/01-core.xml index 1b616375b240..4cb85de5f241 100644 --- a/AnkiDroid/src/main/res/values-pt-rBR/01-core.xml +++ b/AnkiDroid/src/main/res/values-pt-rBR/01-core.xml @@ -127,8 +127,8 @@ Ler e escrever para o banco de dados do AnkiDroid acessar notas, cartões, tipos de notas e baralhos existentes, bem como criar novos - Navegador de Cartões - Cartão do Anki + Card browser + Anki card As coleções não podem ser combinadas.\nQual coleção deseja manter? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-pt-rBR/10-preferences.xml b/AnkiDroid/src/main/res/values-pt-rBR/10-preferences.xml index c58e51eae1b5..001c6b4c6332 100644 --- a/AnkiDroid/src/main/res/values-pt-rBR/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-pt-rBR/10-preferences.xml @@ -107,7 +107,7 @@ Toque com três dedos Toque com quatro dedos Agitar o dispositivo - ‘%s’ Menu + ‘%s’ menu Habilita o menu de contexto globalmente ‘%s\' Rolagem dupla Duplicar a lacuna de rolagem com eReader diff --git a/AnkiDroid/src/main/res/values-pt-rPT/01-core.xml b/AnkiDroid/src/main/res/values-pt-rPT/01-core.xml index 31097b265f47..70e2f1e407a5 100644 --- a/AnkiDroid/src/main/res/values-pt-rPT/01-core.xml +++ b/AnkiDroid/src/main/res/values-pt-rPT/01-core.xml @@ -127,8 +127,8 @@ Ler e escrever na base de dados do AnkiDroid aceder às notas, fichas, tipos de nota e baralhos existentes, bem como criar novos - Explorador de fichas - Ficha Anki + Card browser + Anki card As colecções não podem ser combinadas.\nQue colecção quer manter? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-pt-rPT/10-preferences.xml b/AnkiDroid/src/main/res/values-pt-rPT/10-preferences.xml index adb380800ec2..af95d2feb056 100644 --- a/AnkiDroid/src/main/res/values-pt-rPT/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-pt-rPT/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Agitar o dispositivo - Menu ‘%s’ + ‘%s’ menu Activa o menu contextual ‘%s’, globalmente Scroll duplo Duplicar a velocidade de scroll com o eReader diff --git a/AnkiDroid/src/main/res/values-ro/01-core.xml b/AnkiDroid/src/main/res/values-ro/01-core.xml index f1800103b15d..da37b48c24db 100644 --- a/AnkiDroid/src/main/res/values-ro/01-core.xml +++ b/AnkiDroid/src/main/res/values-ro/01-core.xml @@ -132,8 +132,8 @@ Citește și scrie în baza de date a AnkiDroid accesați notițe, cărți, tipuri de notițe, și pachete și creați altele noi - Browser de cărți - Cartonaș Anki + Card browser + Anki card Colecțiile nu pot fi combinate.\nCe colecție doriți să păstrați? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ro/10-preferences.xml b/AnkiDroid/src/main/res/values-ro/10-preferences.xml index a275ba2f712c..a0cca83cbf9f 100644 --- a/AnkiDroid/src/main/res/values-ro/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ro/10-preferences.xml @@ -108,7 +108,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-ru/01-core.xml b/AnkiDroid/src/main/res/values-ru/01-core.xml index e6f9343a0cad..f98e5fe8eca4 100644 --- a/AnkiDroid/src/main/res/values-ru/01-core.xml +++ b/AnkiDroid/src/main/res/values-ru/01-core.xml @@ -137,8 +137,8 @@ Чтение и запись в базу данных AnkiDroid доступ к колодам, карточкам, записям, их типам, а также создание новых - Список карточек - Карточка Anki + Card browser + Anki card Коллекции не могут быть объединены.\nКакую коллекцию вы хотите оставить? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ru/10-preferences.xml b/AnkiDroid/src/main/res/values-ru/10-preferences.xml index 2feacd0c62e9..6e7af8956f89 100644 --- a/AnkiDroid/src/main/res/values-ru/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ru/10-preferences.xml @@ -109,7 +109,7 @@ Касание тремя пальцами Касание четырьмя пальцами Потрясти устройство - Пункт «%s» в меню + ‘%s’ menu Добавляет пункт «%s» в контекстное меню системы Двойная прокрутка Удвоить дистанцию прокрутки на читалках diff --git a/AnkiDroid/src/main/res/values-sat/01-core.xml b/AnkiDroid/src/main/res/values-sat/01-core.xml index 6cf6b04e2737..b4593d18291a 100644 --- a/AnkiDroid/src/main/res/values-sat/01-core.xml +++ b/AnkiDroid/src/main/res/values-sat/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ᱥᱟᱫᱠᱷᱤᱭᱟᱽ ᱵᱟᱹᱭᱥᱟᱣ ᱨᱮ ᱯᱟᱲᱦᱟᱣ ᱟᱨ ᱚᱞ ᱪᱷᱚᱭ ᱢᱮ ᱢᱟᱲᱟᱝ ᱠᱷᱚᱱ ᱢᱮᱱᱟᱜ ᱠᱷᱟᱴᱚ ᱵᱤᱪᱟᱹᱨᱥ, ᱠᱟᱰ, ᱠᱷᱟᱴᱚ ᱵᱤᱪᱟᱹᱨᱥ ᱯᱨᱚᱠᱟᱨ ᱟᱨ ᱰᱮᱠ ᱠᱚ ᱦᱚᱠ ᱫᱟᱲᱮᱭᱟᱼᱜᱢ ᱚᱱᱟ ᱥᱟᱞᱟᱜ ᱛᱮ ᱱᱟᱶᱟ ᱠᱟᱰ ᱦᱚᱸ ᱛᱮᱭᱟᱨ ᱫᱟᱲᱮᱭᱟᱼᱜᱢ - ᱠᱟᱰ ᱵᱨᱟᱩᱡᱚᱨ - Anki ᱠᱟᱰ + Card browser + Anki card ᱦᱟᱞᱟᱝᱟᱜ ᱠᱚ ᱢᱮᱥᱟ ᱵᱟᱝ ᱜᱟᱱᱚᱜ-ᱟ ।\nᱟᱢ ᱚᱠᱟ ᱦᱟᱞᱟᱝ ᱫᱚᱦᱚ ᱥᱟᱱᱟ ᱮᱫ ᱢᱤᱭᱟ? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-sat/10-preferences.xml b/AnkiDroid/src/main/res/values-sat/10-preferences.xml index 74a938e4a873..1b28a1c24217 100644 --- a/AnkiDroid/src/main/res/values-sat/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sat/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap ᱥᱤᱠᱷᱱᱟᱹᱛ ᱚᱱᱚᱞ - ‘%s’ ᱢᱚᱱᱩ + ‘%s’ menu ᱠᱚᱱᱴᱮᱠᱥᱴ ᱢᱮᱱᱩ ᱠᱡᱟᱜᱟᱛ ᱨᱮ ‘%s’ ᱦᱩᱭ ᱦᱚᱪᱚ ᱫᱚᱦᱲᱟ ᱥᱠᱨᱚᱞ ᱤᱼᱨᱤᱰᱚᱨ ᱮᱨ ᱥᱠᱨᱚᱞ ᱜᱮᱯ ᱫᱚᱦᱲᱟᱭ ᱢᱮ diff --git a/AnkiDroid/src/main/res/values-sc/01-core.xml b/AnkiDroid/src/main/res/values-sc/01-core.xml index 057c7cf5d068..318693d57152 100644 --- a/AnkiDroid/src/main/res/values-sc/01-core.xml +++ b/AnkiDroid/src/main/res/values-sc/01-core.xml @@ -130,8 +130,8 @@ Leghe e iscrie in sa base de datos de AnkiDroid atzede a sas notas, a sas cartas, a sas castas de notas e a sos matzos chi esistint giai, e crea·nde de noos - Navigadore de cartas - Carta Anki + Card browser + Anki card Sas colletziones non si podent cumbinare.\n Cale colletzione cheres mantènnere·? diff --git a/AnkiDroid/src/main/res/values-sc/10-preferences.xml b/AnkiDroid/src/main/res/values-sc/10-preferences.xml index c140cf2e2a87..88f42d75e941 100644 --- a/AnkiDroid/src/main/res/values-sc/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sc/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Iscutzina su dispositivu - Menù ‘%s’ + ‘%s’ menu Abilitat su menù de cuntestu ‘%s’ globalmente Iscurrimentu dòpiu Addopiat sa distàntzia de iscurrimentu cun eReader diff --git a/AnkiDroid/src/main/res/values-sk/01-core.xml b/AnkiDroid/src/main/res/values-sk/01-core.xml index c4c0cd943eb3..b68b1e91384d 100644 --- a/AnkiDroid/src/main/res/values-sk/01-core.xml +++ b/AnkiDroid/src/main/res/values-sk/01-core.xml @@ -137,8 +137,8 @@ Čítať a písať do databázy AnkiDroid prístup k existujúcim poznámkam, kartičkám, typom poznámok, balíčkom ako aj vytváranie nových - Prehliadač kartičiek - Anki kartička + Card browser + Anki card Kolekcie sa nedajú skombinovať.\nKtorú kolekciu chcete ponechať? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-sk/02-strings.xml b/AnkiDroid/src/main/res/values-sk/02-strings.xml index 283fded7887d..6dc5a1a5f74d 100644 --- a/AnkiDroid/src/main/res/values-sk/02-strings.xml +++ b/AnkiDroid/src/main/res/values-sk/02-strings.xml @@ -33,7 +33,7 @@ Typ: Štítky: %1$s Kartičky: %1$s - Edit occlusions + Upraviť doplňovačky Paste from clipboard Pridať/filtrovať štítky Pridať štítok diff --git a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml index 88a0808d3396..1cc5ed864431 100644 --- a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml @@ -52,9 +52,9 @@ Zvýšiť/znížiť („-3”) dnešný limit nových kartičiek o Zvýšiť/znížiť („-3”) dnešný limit kartičiek na zopakovanie o Zopakovať si kartičky zabudnuté za posledných x dní: - Review cards due in the next: + Zopakovať kartičky na skúšanie za: Ukážka nových kartičiek pridaných za posledných x dní: - Select x cards from the deck: + Vybrať x kartičiek z balíčka: Úložisko je plné Úložisko je takmer plné Nedostatok miesta @@ -258,12 +258,12 @@ Card numbers will remain unchanged - If changing to a regular note type, and there are more cloze deletions than available card templates, any extra cards will be removed. + Pri zmene na bežný typ poznámky a ak je tam viacej doplňovacích miest ako dostupných šablón kartičiek, akékoľvek nadbytočné karty budú odstránené. Please select notes from only one notetype. No changes to save Vybrať farbu - Fields + Polia Special %1$s’]]> The front template content. Audio is not automatically played @@ -275,8 +275,8 @@ The name of the card template%s The name of the note type%s - From - To + Z + Na Profile name Profile name can’t be empty diff --git a/AnkiDroid/src/main/res/values-sk/07-cardbrowser.xml b/AnkiDroid/src/main/res/values-sk/07-cardbrowser.xml index 4b3fff26bf55..43099f0b5138 100644 --- a/AnkiDroid/src/main/res/values-sk/07-cardbrowser.xml +++ b/AnkiDroid/src/main/res/values-sk/07-cardbrowser.xml @@ -100,7 +100,7 @@ Pridať stĺpec Odobrať stĺpec Please save your changes first - Retrieving fields names… + Načítavanie názvov polí… See more See less diff --git a/AnkiDroid/src/main/res/values-sk/10-preferences.xml b/AnkiDroid/src/main/res/values-sk/10-preferences.xml index 6bd67a128d9f..91c8e5159415 100644 --- a/AnkiDroid/src/main/res/values-sk/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sk/10-preferences.xml @@ -109,7 +109,7 @@ Dotyk tromi prstami Dotyk štyrmi prstami Pri zatrasení - Menu „%s“ + ‘%s’ menu Povoliť globálne kontextové menu pre „%s“ Dvojitý posun Zdvojnásobiť posúvanie s eReader diff --git a/AnkiDroid/src/main/res/values-sl/01-core.xml b/AnkiDroid/src/main/res/values-sl/01-core.xml index 6e01f2fabcea..311395dfbfc1 100644 --- a/AnkiDroid/src/main/res/values-sl/01-core.xml +++ b/AnkiDroid/src/main/res/values-sl/01-core.xml @@ -137,8 +137,8 @@ Branje in pisanje v podatkovno zbirko AnkiDroid access existing notes, cards, note types, and decks, as well as create new ones - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-sl/10-preferences.xml b/AnkiDroid/src/main/res/values-sl/10-preferences.xml index a7952ee8bf26..d6c3256bf9d0 100644 --- a/AnkiDroid/src/main/res/values-sl/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sl/10-preferences.xml @@ -109,7 +109,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Dvojni pomik Podvoji razmik pomikanja z eReaderjem diff --git a/AnkiDroid/src/main/res/values-sq/01-core.xml b/AnkiDroid/src/main/res/values-sq/01-core.xml index e400e5921ceb..351482c7ecb5 100644 --- a/AnkiDroid/src/main/res/values-sq/01-core.xml +++ b/AnkiDroid/src/main/res/values-sq/01-core.xml @@ -127,8 +127,8 @@ Lexoni dhe shkruani në bazën e të dhënave AnkiDroid aksesoni shënimet, kartat, llojet e shënimeve dhe kuvertat ekzistuese, si dhe krijoni të reja - Shfletuesi i kartave - Karta Anki + Card browser + Anki card Koleksionet nuk mund të kombinohen.\nCilin koleksion dëshironi të mbani? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-sq/10-preferences.xml b/AnkiDroid/src/main/res/values-sq/10-preferences.xml index 1c1005d6efa0..835edd57643c 100644 --- a/AnkiDroid/src/main/res/values-sq/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sq/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-sr/01-core.xml b/AnkiDroid/src/main/res/values-sr/01-core.xml index 25a8c678712c..432a9ef8d94f 100644 --- a/AnkiDroid/src/main/res/values-sr/01-core.xml +++ b/AnkiDroid/src/main/res/values-sr/01-core.xml @@ -132,8 +132,8 @@ Читати и писати у бази података AnkiDroid Приступи постојећим белешкама, картицама, типовима белешки и сетовима и креирај нове - Прегледач картица - Анки картица + Card browser + Anki card Ове колекције није могуће објединити.\nЖелиш ли да сачуваш једну од њих? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-sr/10-preferences.xml b/AnkiDroid/src/main/res/values-sr/10-preferences.xml index 842f9f1a401c..4af3b5744a39 100644 --- a/AnkiDroid/src/main/res/values-sr/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sr/10-preferences.xml @@ -108,7 +108,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Дупло проклизавање Удвостручи међупросторе између клизаче на е-Читалацу diff --git a/AnkiDroid/src/main/res/values-sv/01-core.xml b/AnkiDroid/src/main/res/values-sv/01-core.xml index 9ca05aad5390..169a0ab21d4c 100644 --- a/AnkiDroid/src/main/res/values-sv/01-core.xml +++ b/AnkiDroid/src/main/res/values-sv/01-core.xml @@ -127,8 +127,8 @@ Läs och skriv till AnkiDroid-databasen åtkomst till befintliga noter, kort, nottyper och kortlekar, samt att skapa nya - Kortbläddrare - Anki-kort + Card browser + Anki card Samlingarna kan inte kombineras.\nVilken samling vill du behålla? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-sv/10-preferences.xml b/AnkiDroid/src/main/res/values-sv/10-preferences.xml index 66de96df36c4..08fa1397d470 100644 --- a/AnkiDroid/src/main/res/values-sv/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sv/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Skaka enhet - ‘%s’ Meny + ‘%s’ menu Aktiverar sammanhangsmenyn ‘%s’ globalt Dubbelrullning Aktivera detta om du vill dubbla scroll gap med eReader diff --git a/AnkiDroid/src/main/res/values-ta/01-core.xml b/AnkiDroid/src/main/res/values-ta/01-core.xml index 6d94d0670530..0696f54e4922 100644 --- a/AnkiDroid/src/main/res/values-ta/01-core.xml +++ b/AnkiDroid/src/main/res/values-ta/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid டேட்டாபேஸ் எழுதப் தற்போதுள்ள குறிப்புகள், அட்டைகள், குறிப்பு வகைகள் மற்றும் அடுக்குகள் அணுக, அத்துடன் புதிய ஒன்றை உருவாக்க - அட்டை உலாவி - அங்கி கார்டு + Card browser + Anki card சேகரிப்புகளை இணைக்க முடியாது.\nஎந்த தொகுப்பை வைத்திருக்க விரும்புகிறீர்கள்? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ta/10-preferences.xml b/AnkiDroid/src/main/res/values-ta/10-preferences.xml index cfb8f0bebc74..28a8562420a7 100644 --- a/AnkiDroid/src/main/res/values-ta/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ta/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap சாதனத்தை அசைக்கவும் - ‘%s’ மெனு + ‘%s’ menu உலகளவில் சூழல் மெனு \'%s\' ஐ இயக்குகிறது இரட்டை ஸ்க்ரோலிங் eReader மூலம் ஸ்க்ரோல் இடைவெளியை இரட்டிப்பாக்கவும் diff --git a/AnkiDroid/src/main/res/values-te/01-core.xml b/AnkiDroid/src/main/res/values-te/01-core.xml index ad96fe8e25d3..634cebcf47e4 100644 --- a/AnkiDroid/src/main/res/values-te/01-core.xml +++ b/AnkiDroid/src/main/res/values-te/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid డేటాబేస్కు చదవండి మరియు వ్రాయండి పొందు ప్రస్తుతం ఉన్న గమనికలు, కార్డులు, నోట్ రకాలు మరియు డెక్స్, కొత్త వాటిని సృష్టించడం - కార్డ్ బ్రౌజర్ - అంకీ కార్డ్ + Card browser + Anki card సేకరణలను కలపడం సాధ్యం కాదు.\nమీరు ఏ సేకరణను ఉంచాలనుకుంటున్నారు? అంకిడ్రాయిడ్ diff --git a/AnkiDroid/src/main/res/values-te/10-preferences.xml b/AnkiDroid/src/main/res/values-te/10-preferences.xml index aa4065bade80..79709b9da69f 100644 --- a/AnkiDroid/src/main/res/values-te/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-te/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap షేక్ పరికరం - \'%s\' మెను + ‘%s’ menu ప్రపంచవ్యాప్తంగా \'%s\' సందర్భ మెనుని ప్రారంభిస్తుంది డబుల్ స్క్రోలింగ్ eReader తో డబుల్ స్క్రోల్ స్పేస్ diff --git a/AnkiDroid/src/main/res/values-tgl/01-core.xml b/AnkiDroid/src/main/res/values-tgl/01-core.xml index 0e5b24713b8e..f49a1208e231 100644 --- a/AnkiDroid/src/main/res/values-tgl/01-core.xml +++ b/AnkiDroid/src/main/res/values-tgl/01-core.xml @@ -131,8 +131,8 @@ Burahin ang “%2$s” uri ng baraha, at ang %1$d mga baraha? Pagbasa at pagsulat sa database ng AnkiDroid i-access ang mga paalala, baraha, uri ng paalala, mga deck, at gumawa ng mga bago - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-tgl/10-preferences.xml b/AnkiDroid/src/main/res/values-tgl/10-preferences.xml index 5d9f809d3212..6b55d1dc43ca 100644 --- a/AnkiDroid/src/main/res/values-tgl/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-tgl/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scroll I-double ang scroll puwang sa eReader diff --git a/AnkiDroid/src/main/res/values-th/01-core.xml b/AnkiDroid/src/main/res/values-th/01-core.xml index 92858d7e321d..34bc0c9dc152 100644 --- a/AnkiDroid/src/main/res/values-th/01-core.xml +++ b/AnkiDroid/src/main/res/values-th/01-core.xml @@ -122,8 +122,8 @@ อ่านและเขียน ลงใน ฐานข้อมูล AnkiDroid\" เข้าถึงโน้ต, การ์ด, ชนิดโน้ต และชุดการ์ดที่มีอยู่ รวมถึงสร้างรายการใหม่ๆ ได้ - ตัวเรียกดูการ์ด - Anki Card + Card browser + Anki card ไม่สามารถรวมคอลเลกชันเข้าด้วยกันได้ \n คุณต้องการเก็บคอลเลกชันไหนไว้? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-th/10-preferences.xml b/AnkiDroid/src/main/res/values-th/10-preferences.xml index a0ce661dd0fe..f9681201b59a 100644 --- a/AnkiDroid/src/main/res/values-th/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-th/10-preferences.xml @@ -106,7 +106,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-ti/01-core.xml b/AnkiDroid/src/main/res/values-ti/01-core.xml index b25e9097ff49..8522f75a8a5c 100644 --- a/AnkiDroid/src/main/res/values-ti/01-core.xml +++ b/AnkiDroid/src/main/res/values-ti/01-core.xml @@ -127,8 +127,8 @@ ምጽሓፍን ምንባብን ናብ AnkiDroid database ዝነበሩ ጽሑፋት፣ ካርድታት፣ ዓይነት ጽሑፋት፣ ባይታታት ከምኡ ውን ሓደሽቲ ከምዚኣቶም ዝኣመሰሉ ርከቦም። - Card Browser - Anki Card + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ti/10-preferences.xml b/AnkiDroid/src/main/res/values-ti/10-preferences.xml index 5f071239db6a..5fba3e8c30c5 100644 --- a/AnkiDroid/src/main/res/values-ti/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ti/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Menu + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-tr/01-core.xml b/AnkiDroid/src/main/res/values-tr/01-core.xml index 98b804b00e0e..eb2913e81542 100644 --- a/AnkiDroid/src/main/res/values-tr/01-core.xml +++ b/AnkiDroid/src/main/res/values-tr/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid veritabanına okut ve yaz varolan notlara, kartlara, not türlerine ve destelere erişmek, ve yenilerini oluşturmak - Kart Tarayıcısı - Anki Kart + Card browser + Anki card Koleksiyonlar birleştirilemiyor.\nHangi koleksiyonu tutmak istersiniz? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-tr/10-preferences.xml b/AnkiDroid/src/main/res/values-tr/10-preferences.xml index e496e2867c6f..c06f2a442f63 100644 --- a/AnkiDroid/src/main/res/values-tr/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-tr/10-preferences.xml @@ -107,7 +107,7 @@ Üç parmakla dokunma Dört parmakla dokunma Cihazı salla - \"%s\" Menüsü + ‘%s’ menu \"%s\" durum menüsünü uygulama çapında etkinleştirir Hızlı kaydırma eOkuyucu\'yla kaydırma mesafesini iki katına çıkarmak istiyorsanız bunu etkinleştirin diff --git a/AnkiDroid/src/main/res/values-tt/01-core.xml b/AnkiDroid/src/main/res/values-tt/01-core.xml index 4adfbf577321..95c0bb37ba98 100644 --- a/AnkiDroid/src/main/res/values-tt/01-core.xml +++ b/AnkiDroid/src/main/res/values-tt/01-core.xml @@ -122,8 +122,8 @@ AnkiDroid мәгълүмәт базасына уку һәм язу булган язуларга, кәртләргә, язу төрләренә рөхсәт һәм яңаларны ясау - Кәрт браузеры - Anki Кәрт + Card browser + Anki card The collections can’t be combined.\nWhich collection do you want to keep? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-tt/10-preferences.xml b/AnkiDroid/src/main/res/values-tt/10-preferences.xml index 05cb821dd0ad..0fa15a5f122f 100644 --- a/AnkiDroid/src/main/res/values-tt/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-tt/10-preferences.xml @@ -106,7 +106,7 @@ Three-finger tap Four-finger tap Shake device - ‘%s’ Меню + ‘%s’ menu Enables the ‘%s’ context menu globally Double scrolling Double the scroll gap with eReader diff --git a/AnkiDroid/src/main/res/values-ug/01-core.xml b/AnkiDroid/src/main/res/values-ug/01-core.xml index d44d8883010d..21d261445901 100644 --- a/AnkiDroid/src/main/res/values-ug/01-core.xml +++ b/AnkiDroid/src/main/res/values-ug/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ساندانىنى ئوقۇيدۇ ۋە يازىدۇ مەۋجۇت خاتىرە، كارتا، خاتىرە تۈرى ۋە دەستىنى زىيارەت قىلىپلا قالماي يەنە يېڭىسىنى قۇرىدۇ - كارتا كۆرگۈچ - Anki كارتا + Card browser + Anki card توپلامنى بىرلەشتۈرگىلى بولمايدۇ.\nقايسى توپلامنى ساقلاپ قالىسىز؟ AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ug/10-preferences.xml b/AnkiDroid/src/main/res/values-ug/10-preferences.xml index 09b2fc436350..203da91ef4de 100644 --- a/AnkiDroid/src/main/res/values-ug/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ug/10-preferences.xml @@ -107,7 +107,7 @@ ئۈچ بارماقتا چېكىش تۆت بارماقتا چېكىش ئۈسكۈنىنى سىلكى - «%s» تىزىملىكى + ‘%s’ menu «%s» مەزمۇن تىزىملىكىنى پۈتكۈل دائىرىدە قوزغىتىدۇ قوش دومىلات ئېلېكتىرونلۇق ئوقۇغۇچتا قوش دومىلىتىدۇ diff --git a/AnkiDroid/src/main/res/values-uk/01-core.xml b/AnkiDroid/src/main/res/values-uk/01-core.xml index e3d1fc3fd908..f53dfe0b9c0c 100644 --- a/AnkiDroid/src/main/res/values-uk/01-core.xml +++ b/AnkiDroid/src/main/res/values-uk/01-core.xml @@ -137,8 +137,8 @@ Читати й записувати в базу даних AnkiDroid доступ до наявних карток, колод, записів та їх типів, а також створення нових - Переглядач карток - Anki картка + Card browser + Anki card Колекції не можна поєднати.\nЯку колекцію Ви хочете зберегти? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-uk/10-preferences.xml b/AnkiDroid/src/main/res/values-uk/10-preferences.xml index 0362d3f60109..9324663d56e6 100644 --- a/AnkiDroid/src/main/res/values-uk/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-uk/10-preferences.xml @@ -109,7 +109,7 @@ Дотик трьома пальцями Дотик чотирма пальцями Потрясти пристроєм - Меню \'%s\' + ‘%s’ menu Включає \'%s\' в загальне контекстне меню Подвійне прокручування Подвоїти розрив прокручування в eReader-і diff --git a/AnkiDroid/src/main/res/values-ur/01-core.xml b/AnkiDroid/src/main/res/values-ur/01-core.xml index 495bd8f743f0..e3d2f8155247 100644 --- a/AnkiDroid/src/main/res/values-ur/01-core.xml +++ b/AnkiDroid/src/main/res/values-ur/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ڈیٹا بیس کو پڑھیں اور لکھیں۔ موجودہ نوٹوں، کارڈز، نوٹ کی اقسام، اور ڈیک تک رسائی حاصل کریں، اور ساتھ ہی نئے بنائیں - کارڈ براؤزر - انکی کارڈ + Card browser + Anki card مجموعوں کو یکجا نہیں کیا جا سکتا۔\nآپ کون سا مجموعہ رکھنا چاہتے ہیں؟ آنکی ڈیرائڈ diff --git a/AnkiDroid/src/main/res/values-ur/10-preferences.xml b/AnkiDroid/src/main/res/values-ur/10-preferences.xml index 4a9961c595f3..c14ae771dc94 100644 --- a/AnkiDroid/src/main/res/values-ur/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ur/10-preferences.xml @@ -107,7 +107,7 @@ Three-finger tap Four-finger tap شیک ڈیوائس - \'%s\' مینو + ‘%s’ menu عالمی سطح پر \'%s\' سیاق و سباق کے مینو کو فعال کرتا ہے۔ ڈبل اسکرولنگ eReader کے ساتھ اسکرول گیپ کو دوگنا کریں diff --git a/AnkiDroid/src/main/res/values-uz/01-core.xml b/AnkiDroid/src/main/res/values-uz/01-core.xml index e30955227b2b..fbd04e13129e 100644 --- a/AnkiDroid/src/main/res/values-uz/01-core.xml +++ b/AnkiDroid/src/main/res/values-uz/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid maʼlumotlar bazasidan oʼqish va yozish mavjud qaydlarga, kartalarga, qayd turlariga va dastalarga, shuningdek, yangilarini yaratishga ruxsat - Karta brauzeri - Anki Karta + Card browser + Anki card Kolleksiyalarni birlashtirib boʻlmadi.\n Qaysi kolleksiyani qoldirmoqchisiz? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-uz/10-preferences.xml b/AnkiDroid/src/main/res/values-uz/10-preferences.xml index 85483237b48f..e0c9473ae575 100644 --- a/AnkiDroid/src/main/res/values-uz/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-uz/10-preferences.xml @@ -107,7 +107,7 @@ Uch barmoq bilan bosish Toʻrt barmoq bilan bosish Qurilmani silkitish - ‘%s’ menyusi + ‘%s’ menu “%s” kontekst menyusini ilova miqyosda yoqadi Tez aylantirish Agar siz eReader bilan aylanma masofani ikki baravar oshirishni istasangiz, buni yoqing diff --git a/AnkiDroid/src/main/res/values-vi/01-core.xml b/AnkiDroid/src/main/res/values-vi/01-core.xml index 4b63e784d641..248e9d8d9077 100644 --- a/AnkiDroid/src/main/res/values-vi/01-core.xml +++ b/AnkiDroid/src/main/res/values-vi/01-core.xml @@ -122,8 +122,8 @@ Đọc và ghi vào cơ sở dữ liệu AnkiDroid truy cập các ghi chú, thẻ, loại ghi chú và các bộ thẻ hiện có, cũng như tạo mới chúng - Trình duyệt Thẻ - Thẻ Anki + Card browser + Anki card Các bộ sưu tập không thể đồng nhất.\nBạn muốn giữ lại bộ sưu tập nào? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-vi/02-strings.xml b/AnkiDroid/src/main/res/values-vi/02-strings.xml index 138d414250e9..7fca57c6a95a 100644 --- a/AnkiDroid/src/main/res/values-vi/02-strings.xml +++ b/AnkiDroid/src/main/res/values-vi/02-strings.xml @@ -248,12 +248,12 @@ + - Giá trị không hợp lệ - Maximum value is %d - Minimum value is %d + Giá trị tối đa là %d + Giá trị tối thiểu là %d Nhập email hợp lệ Bắt buộc có mật khẩu - Go back again to exit + Quay lại lần nữa để thoát %s%% Tải bộ thẻ xuống diff --git a/AnkiDroid/src/main/res/values-vi/10-preferences.xml b/AnkiDroid/src/main/res/values-vi/10-preferences.xml index 8e607fff5cd2..3d9951c0872a 100644 --- a/AnkiDroid/src/main/res/values-vi/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-vi/10-preferences.xml @@ -106,7 +106,7 @@ Chạm bằng ba ngón tay Chạm bằng bốn ngón tay Lắc thiết bị - Menu ‘%s’ + ‘%s’ menu Bật menu ngữ cảnh ‘%s’ trên toàn cầu Bấm phím scrolling (chuột giữa) hai lần bấm hai lần chuột giữa với eReader diff --git a/AnkiDroid/src/main/res/values-zh-rCN/01-core.xml b/AnkiDroid/src/main/res/values-zh-rCN/01-core.xml index 18d96981c5ee..503c46749be4 100644 --- a/AnkiDroid/src/main/res/values-zh-rCN/01-core.xml +++ b/AnkiDroid/src/main/res/values-zh-rCN/01-core.xml @@ -122,8 +122,8 @@ 读取并写入 AnkiDroid 数据库 创建或访问现有的笔记、卡片、笔记模板和牌组 - 卡片浏览器 - Anki 卡片 + Card browser + Anki card 集合无法合并。\n您想要保留哪个集合? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml b/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml index a9c4f4853523..7d487a11c9ab 100644 --- a/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml @@ -106,7 +106,7 @@ 三指轻触 四指轻触 摇动设备 - “%s” 菜单 + ‘%s’ menu 全局启用 “%s” 上下文菜单 双滚动 电子阅读器中使用双滚动 diff --git a/AnkiDroid/src/main/res/values-zh-rTW/01-core.xml b/AnkiDroid/src/main/res/values-zh-rTW/01-core.xml index b5071127ddf3..4bfd73203de4 100644 --- a/AnkiDroid/src/main/res/values-zh-rTW/01-core.xml +++ b/AnkiDroid/src/main/res/values-zh-rTW/01-core.xml @@ -123,8 +123,8 @@ 讀寫 AnkiDroid 資料庫 瀏覽現有的筆記、卡片、筆記類型和牌組,或者自己動手建立新的~ - 瀏覽卡片 - Anki卡 + Card browser + Anki card 收藏無法合併。 \n您要保留哪個收藏? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-zh-rTW/10-preferences.xml b/AnkiDroid/src/main/res/values-zh-rTW/10-preferences.xml index 6fa2a4e577ef..c77ee9a51afe 100644 --- a/AnkiDroid/src/main/res/values-zh-rTW/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-zh-rTW/10-preferences.xml @@ -106,7 +106,7 @@ 三指輕觸 四指輕觸 搖晃裝置 - ‘%s’ 選單 + ‘%s’ menu 啟用全域性‘%s’內容選單 加速捲動 加倍 eReader 的滾動間距 From fda896a81b42e456d110abbc12ed9d31d236e0a1 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:24:35 +0100 Subject: [PATCH 47/54] docs(proguard): explain BuildConfig exception Review comment on 22004 Assisted-by: GPT-6 --- AnkiDroid/proguard-rules.pro | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/proguard-rules.pro b/AnkiDroid/proguard-rules.pro index 6d6f31bb856f..c65148db65a3 100644 --- a/AnkiDroid/proguard-rules.pro +++ b/AnkiDroid/proguard-rules.pro @@ -24,7 +24,8 @@ # -dontobfuscate # Used through Reflection -# ACRA reads BuildConfig fields 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 ; } From 1b072cafcc4afd212eff597f529785b97eecbdf6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:56:47 +0100 Subject: [PATCH 48/54] refactor(lint): replace FixedPreferencesTitleLength with a JVM test Lint rules cannot correlate android:title="@string/..." usages with string definitions in `:common:android`. They need to be in the same module Part of 21500 Assisted-by: Claude Fable 5 --- .../ichi2/anki/PreferenceTitleLengthTest.kt | 107 ++++++++ .../java/com/ichi2/anki/lint/IssueRegistry.kt | 5 - .../lint/rules/FixedPreferencesTitleLength.kt | 230 ------------------ .../rules/FixedPreferencesTitleLengthTest.kt | 151 ------------ 4 files changed, 107 insertions(+), 386 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/PreferenceTitleLengthTest.kt delete mode 100644 lint-rules/src/main/java/com/ichi2/anki/lint/rules/FixedPreferencesTitleLength.kt delete mode 100644 lint-rules/src/test/java/com/ichi2/anki/lint/rules/FixedPreferencesTitleLengthTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/PreferenceTitleLengthTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/PreferenceTitleLengthTest.kt new file mode 100644 index 000000000000..02f21824ff0e --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/PreferenceTitleLengthTest.kt @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import org.junit.Test +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.test.fail + +/** + * Preference titles are limited to 41 characters and menu titles to 28, with a matching + * `maxLength` attribute on the `` so CrowdIn rejects over-long translations. + */ +class PreferenceTitleLengthTest { + private companion object { + const val PREFERENCES_TITLE_MAX_LENGTH = 41 + const val MENU_TITLE_MAX_LENGTH = 28 + + val STRING_RESOURCE_DIRS = + listOf( + // test working directory is the module directory: `AnkiDroid/` + File("src/main/res/values"), + File("../common/android/src/main/res/values"), + ) + } + + private val documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder() + + /** name -> (maxLength attribute or null, text content) */ + private data class StringResource( + val maxLength: Int?, + val text: String, + ) + + private val stringResources: Map by lazy { + val result = mutableMapOf() + for (dir in STRING_RESOURCE_DIRS) { + val files = dir.listFiles { file -> file.extension == "xml" } ?: error("Could not list files in $dir") + for (file in files) { + val strings = documentBuilder.parse(file).getElementsByTagName("string") + for (i in 0 until strings.length) { + val element = strings.item(i) + val name = element.attributes.getNamedItem("name")?.nodeValue ?: continue + val maxLength = + element.attributes + .getNamedItem("maxLength") + ?.nodeValue + ?.toInt() + result[name] = StringResource(maxLength, element.textContent) + } + } + } + result + } + + /** Names of the string resources used as `android:title` in the given res folder. */ + private fun titlesIn(folder: String): Set { + val files = File("src/main/res/$folder").listFiles { file -> file.extension == "xml" } ?: error("Could not list res/$folder") + val titles = mutableSetOf() + for (file in files) { + val elements = documentBuilder.parse(file).getElementsByTagName("*") + for (i in 0 until elements.length) { + val title = + elements + .item(i) + .attributes + ?.getNamedItem("android:title") + ?.nodeValue ?: continue + val stringName = title.substringAfter("@string/", "").ifEmpty { continue } + titles.add(stringName) + } + } + return titles + } + + private fun checkTitles( + folder: String, + maxLength: Int, + ) { + val failures = mutableListOf() + for (title in titlesIn(folder)) { + val resource = stringResources[title] + if (resource == null) { + failures.add("'$title' is not defined in any of $STRING_RESOURCE_DIRS") + continue + } + when { + resource.maxLength == null -> + failures.add("'$title' is missing the maxLength=\"$maxLength\" attribute") + resource.maxLength > maxLength -> + failures.add("'$title' has maxLength=\"${resource.maxLength}\"; it should be at most $maxLength") + } + if (resource.text.length > maxLength) { + failures.add("'$title' must be less than $maxLength characters (currently ${resource.text.length})") + } + } + if (failures.any()) { + fail("res/$folder titles are too long for small screens/translations:\n" + failures.joinToString("\n")) + } + } + + @Test + fun `preference titles are constrained to 41 characters`() = checkTitles("xml", PREFERENCES_TITLE_MAX_LENGTH) + + @Test + fun `menu titles are constrained to 28 characters`() = checkTitles("menu", MENU_TITLE_MAX_LENGTH) +} diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt index 2e4807b8f14b..ecdd445d1fe6 100644 --- a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt @@ -16,7 +16,6 @@ import com.ichi2.anki.lint.rules.DirectSystemCurrentTimeMillisUsage import com.ichi2.anki.lint.rules.DirectSystemTimeInstantiation import com.ichi2.anki.lint.rules.DirectToastMakeTextUsage import com.ichi2.anki.lint.rules.DuplicateCrowdInStrings -import com.ichi2.anki.lint.rules.FixedPreferencesTitleLength import com.ichi2.anki.lint.rules.HardcodedPreferenceKey import com.ichi2.anki.lint.rules.InvalidStringFormatDetector import com.ichi2.anki.lint.rules.JUnitNullAssertionDetector @@ -54,10 +53,6 @@ class IssueRegistry : IssueRegistry() { OpenInputStreamSafeDetector.ISSUE, SentenceCaseConventions.ISSUE, TranslationTypo.ISSUE, - FixedPreferencesTitleLength.PREFERENCES_ISSUE_MAX_LENGTH, - FixedPreferencesTitleLength.MENU_ISSUE_MAX_LENGTH, - FixedPreferencesTitleLength.PREFERENCES_ISSUE_TITLE_LENGTH, - FixedPreferencesTitleLength.MENU_ISSUE_TITLE_LENGTH, VariableNamingDetector.ISSUE, InvalidStringFormatDetector.ISSUE, AvoidAlertDialogUsage.ISSUE, diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FixedPreferencesTitleLength.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FixedPreferencesTitleLength.kt deleted file mode 100644 index a6e540eb334e..000000000000 --- a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FixedPreferencesTitleLength.kt +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// SPDX-FileCopyrightText: Copyright (c) 2021 Prateek Singh - -package com.ichi2.anki.lint.rules - -import com.android.resources.ResourceFolderType -import com.android.tools.lint.detector.api.Context -import com.android.tools.lint.detector.api.Implementation -import com.android.tools.lint.detector.api.Issue -import com.android.tools.lint.detector.api.Location.Handle -import com.android.tools.lint.detector.api.ResourceXmlDetector -import com.android.tools.lint.detector.api.Scope -import com.android.tools.lint.detector.api.XmlContext -import com.android.tools.lint.detector.api.XmlScanner -import com.ichi2.anki.lint.utils.Constants -import org.w3c.dom.Element -import java.util.Locale - -/** - * Detector for preference's title whose length may be bigger than 41 character and for menu's title whose length may be bigger than 29 characters. - * There is an error for each string which is longer than 41 character in English. - * There is also an error for each string which does not have `maxLength` set to at most 41. - * - * Recall that `title` here is not the title of the preference screen, but the title of the preference entry. That is the text in bold that appears on a single line. - */ -class FixedPreferencesTitleLength : - ResourceXmlDetector(), - XmlScanner { - companion object { - private const val PREFERENCES_ID_TITLE_LENGTH = "FixedPreferencesTitleLength" - private const val MENU_ID_TITLE_LENGTH = "FixedMenuTitleLength" - - private const val PREFERENCES_ID_MAX_LENGTH = "PreferencesTitleMaxLengthAttr" - private const val MENU_ID_MAX_LENGTH = "MenuTitleMaxLengthAttr" - - private const val PREFERENCES_TITLE_MAX_LENGTH = 41 - private const val MENU_TITLE_MAX_LENGTH = 28 - - private const val PREFERENCES_DESCRIPTION_TITLE_LENGTH = - "Preference titles should be less than $PREFERENCES_TITLE_MAX_LENGTH characters" - private const val MENU_DESCRIPTION_TITLE_LENGTH = - "Preference titles should be less than $MENU_TITLE_MAX_LENGTH characters" - - private const val PREFERENCES_DESCRIPTION_MAX_LENGTH = - "Preference titles should contain maxLength=\"$PREFERENCES_TITLE_MAX_LENGTH\" attribute" - private const val MENU_DESCRIPTION_MAX_LENGTH = - """Preference titles should contain maxLength="$MENU_TITLE_MAX_LENGTH" attribute""" - - // Around 42 (resp. 29) characters is a hard max on emulators in preferences (resp. menu), likely smaller in reality, so use a buffer - private const val PREFERENCES_EXPLANATION_TITLE_LENGTH = - "A preference title with more than $PREFERENCES_TITLE_MAX_LENGTH characters " + - "may fail to display on smaller screens" - private const val MENU_EXPLANATION_TITLE_LENGTH = - "A menu title with more than $MENU_TITLE_MAX_LENGTH characters " + - "may fail to display on smaller screens" - - // Read More: https://support.crowdin.com/file-formats/android-xml/ - private const val PREFERENCES_EXPLANATION_MAX_LENGTH = - "Preference Title should contain maxLength attribute because " + - "it fixes translated string length" - private const val MENU_EXPLANATION_MAX_LENGTH = - "Preference Title should contain maxLength attribute because " + - "it fixes translated string length" - - private val implementation = Implementation(FixedPreferencesTitleLength::class.java, Scope.RESOURCE_FILE_SCOPE) - val PREFERENCES_ISSUE_TITLE_LENGTH: Issue = - Issue.create( - PREFERENCES_ID_TITLE_LENGTH, - PREFERENCES_DESCRIPTION_TITLE_LENGTH, - PREFERENCES_EXPLANATION_TITLE_LENGTH, - Constants.ANKI_XML_CATEGORY, - Constants.ANKI_XML_PRIORITY, - Constants.ANKI_XML_SEVERITY, - implementation, - ) - val MENU_ISSUE_TITLE_LENGTH: Issue = - Issue.create( - MENU_ID_TITLE_LENGTH, - MENU_DESCRIPTION_TITLE_LENGTH, - MENU_EXPLANATION_TITLE_LENGTH, - Constants.ANKI_XML_CATEGORY, - Constants.ANKI_XML_PRIORITY, - Constants.ANKI_XML_SEVERITY, - implementation, - ) - - val PREFERENCES_ISSUE_MAX_LENGTH: Issue = - Issue.create( - PREFERENCES_ID_MAX_LENGTH, - PREFERENCES_DESCRIPTION_MAX_LENGTH, - PREFERENCES_EXPLANATION_MAX_LENGTH, - Constants.ANKI_XML_CATEGORY, - Constants.ANKI_XML_PRIORITY, - Constants.ANKI_XML_SEVERITY, - implementation, - ) - val MENU_ISSUE_MAX_LENGTH: Issue = - Issue.create( - MENU_ID_MAX_LENGTH, - MENU_DESCRIPTION_MAX_LENGTH, - MENU_EXPLANATION_MAX_LENGTH, - Constants.ANKI_XML_CATEGORY, - Constants.ANKI_XML_PRIORITY, - Constants.ANKI_XML_SEVERITY, - implementation, - ) - private const val ATTR_TITLE = "android:title" - private const val ATTR_NAME = "name" - private const val ATTR_MAX_LENGTH = "maxLength" - } - - /** - * Titles of the resources in the xml/ folder. - * I.e. after the end of [visitElement], it contains - * "pref__delete_unused_media_files__title" from src/main/res/xml/manage_space - */ - private val titlesOfPreferenceScreens: MutableSet = HashSet() - - /** - * Titles of the resources in the menu/ folder. - */ - private val titlesOfMenuScreens: MutableSet = HashSet() - - /** - * String resources. - * I.e. after the end of [visitElement], it'll map "pref__delete_unused_media_files__title" to the element - * ``` - * Delete unused media files - * ``` - * of src/main/res/values/10-preferences.xml, and its handle. - */ - private val stringResources: MutableMap = HashMap() - - override fun getApplicableElements(): Collection? = ALL - - override fun visitElement( - context: XmlContext, - element: Element, - ) { - if (element.hasAttribute(ATTR_TITLE)) { - val folderName = context.file.parentFile.name - val titlesToHandle = - when (folderName) { - "xml" -> titlesOfPreferenceScreens - "menu" -> titlesOfMenuScreens - else -> return - } - // Add the `android:title`'s resource name (without "@string/") to `stringResources` - // if the element has this attribute and the file belongs to src/main/res/xml. - - // Removing the "@string/" part. - val titleAttribute = element.getAttribute(ATTR_TITLE) - val stringName = titleAttribute.substringAfter("@string/", "").ifEmpty { return } - // the entry `stringName` may already exists. Losing the first entry is not an issue, as it won't actually hide that there are issues. - titlesToHandle.add(stringName) - return - } - if ("values" == context.file.parentFile.name && - "string" == element.tagName - ) { - // Let's consider the `string`s tag of "src/main/res/values/*.xml" - stringResources[element.getAttribute(ATTR_NAME)] = context.createLocationHandle(element).apply { clientData = element } - } - } - - override fun appliesTo(folderType: ResourceFolderType): Boolean = - folderType == ResourceFolderType.XML || folderType == ResourceFolderType.VALUES || folderType == ResourceFolderType.MENU - - override fun afterCheckEachProject(context: Context) { - checkFolder( - context, - titlesOfPreferenceScreens, - PREFERENCES_ISSUE_MAX_LENGTH, - PREFERENCES_ISSUE_MAX_LENGTH, - PREFERENCES_ISSUE_TITLE_LENGTH, - "Preference", - PREFERENCES_TITLE_MAX_LENGTH, - ) - checkFolder( - context, - titlesOfMenuScreens, - MENU_ISSUE_MAX_LENGTH, - MENU_ISSUE_MAX_LENGTH, - MENU_ISSUE_TITLE_LENGTH, - "Menu", - MENU_TITLE_MAX_LENGTH, - ) - } - - fun checkFolder( - context: Context, - titles: Set, - missingMaxLengthIssueIssue: Issue, - wrongMaxLengthIssue: Issue, - stringTooLongIssue: Issue, - folder: String, - maxLength: Int, - ) { - for (title in titles) { - val stringHandle = stringResources[title] ?: throw IllegalArgumentException(title) - val stringElement: Element = stringHandle.clientData as Element - if (!stringElement.hasAttribute(ATTR_MAX_LENGTH)) { - val message = String.format(Locale.ENGLISH, "$folder title '%s' is missing maxLength=\"%d\" attribute.", title, maxLength) - context.report(missingMaxLengthIssueIssue, stringHandle.resolve(), message) - } else if (stringElement.getAttribute(ATTR_MAX_LENGTH).toInt() > maxLength) { - val message = - String.format( - Locale.ENGLISH, - "$folder title '%s' has maxLength=\"%s\". Its max length should be at most %d.", - title, - stringElement.getAttribute(ATTR_MAX_LENGTH), - maxLength, - ) - context.report(wrongMaxLengthIssue, stringHandle.resolve(), message) - } - if (stringElement.textContent.length > maxLength) { - val message = - String.format( - Locale.ENGLISH, - "$folder title '%s' must be less than %d characters (currently %d).", - title, - maxLength, - stringElement.textContent.length, - ) - context.report(stringTooLongIssue, stringHandle.resolve(), message) - } - } - } -} diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FixedPreferencesTitleLengthTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FixedPreferencesTitleLengthTest.kt deleted file mode 100644 index 6e015b2d7d20..000000000000 --- a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FixedPreferencesTitleLengthTest.kt +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// SPDX-FileCopyrightText: Copyright (c) 2021 Prateek Singh - -package com.ichi2.anki.lint.rules - -import com.android.tools.lint.checks.infrastructure.TestFiles -import com.android.tools.lint.checks.infrastructure.TestLintTask -import org.intellij.lang.annotations.Language -import org.junit.Test - -class FixedPreferencesTitleLengthTest { - companion object { - @Language("XML") - val strings1XmlValid = """ - app_name - pref_cat_general_summ - button_sync - sync_account -""" - - @Language("XML") - val strings10XmlValid = """ - sync_account_summ - sync_fetch_missing - Less than limit - number_title -""" - - @Language("XML") - val strings10XmlInvalid = """ - - app_name - pref_cat_general_summ - - button_sync - - sync_account -""" - - @Language("XML") - val strings1XmlInvalid = """ - sync_account_summ - sync_fetch_missing - - This String contains character more than limit - - This String contains a £. -""" - - @Language("XML") - val preferenceString = - """ - - - - - - - -""" - } - - @Language("XML") - val preferenceWithHardcodedTitle = """ - - - """ - - @Test - fun showsErrorForInvalidFile() { - TestLintTask - .lint() - .allowMissingSdk() - .allowCompilationErrors() - .files( - TestFiles.xml("res/xml/preference_general_invalid.xml", preferenceString), - TestFiles.xml("res/values/10-preferences.xml", strings10XmlInvalid), - TestFiles.xml("res/values/01-core.xml", strings1XmlInvalid), - ).issues( - FixedPreferencesTitleLength.PREFERENCES_ISSUE_TITLE_LENGTH, - FixedPreferencesTitleLength.PREFERENCES_ISSUE_MAX_LENGTH, - ).run() - .expectErrorCount(3) - .expect( - """res/values/01-core.xml:5: Error: Preference title 'checkbox_title' must be less than 41 characters (currently 46). [FixedPreferencesTitleLength] - This String contains character more than limit - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -res/values/10-preferences.xml:3: Error: Preference title 'app_name' is missing maxLength="41" attribute. [PreferencesTitleMaxLengthAttr] - app_name - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -res/values/10-preferences.xml:6: Error: Preference title 'button_sync' has maxLength="55". Its max length should be at most 41. [PreferencesTitleMaxLengthAttr] - button_sync - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -3 errors, 0 warnings""", - ) - } - - @Test - fun showsNoErrorForValidFile() { - TestLintTask - .lint() - .allowMissingSdk() - .allowCompilationErrors() - .files( - TestFiles.xml("res/xml/preference_general_valid.xml", preferenceString), - TestFiles.xml("res/values/10-preferences.xml", strings10XmlValid), - TestFiles.xml("res/values/01-core.xml", strings1XmlValid), - ).issues( - FixedPreferencesTitleLength.PREFERENCES_ISSUE_MAX_LENGTH, - FixedPreferencesTitleLength.PREFERENCES_ISSUE_TITLE_LENGTH, - ).run() - .expectClean() - } - - @Test - fun hardcodedTitleIsNotFlagged() { - TestLintTask - .lint() - .allowMissingSdk() - .allowCompilationErrors() - .files( - TestFiles.xml("res/xml/preference_general_valid.xml", preferenceWithHardcodedTitle), - TestFiles.xml("res/values/01-core.xml", strings1XmlValid), - ).issues( - FixedPreferencesTitleLength.PREFERENCES_ISSUE_MAX_LENGTH, - FixedPreferencesTitleLength.PREFERENCES_ISSUE_TITLE_LENGTH, - ).run() - .expectClean() - } -} From 849db565a2c1b09b749ed480d4d951a7a6353f6f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:11:08 +0100 Subject: [PATCH 49/54] build(lint): use `tools:ignore` for 12-dont-translate.xml `tools:ignore` works when the file is in `:common:android`. Part of 21500 Assisted-by: Claude Fable 5 --- AnkiDroid/src/main/res/values/12-dont-translate.xml | 2 +- lint-release.xml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/res/values/12-dont-translate.xml b/AnkiDroid/src/main/res/values/12-dont-translate.xml index 4b384dcdeb88..44edfbfc7dbc 100644 --- a/AnkiDroid/src/main/res/values/12-dont-translate.xml +++ b/AnkiDroid/src/main/res/values/12-dont-translate.xml @@ -13,7 +13,7 @@ If you want to use a string in your code that can't be translated, please use: * constants.xml if the string must be a constant, such as the name of someone or a URL * hard coded string literals if the string should not be translated because it belongs to a feature in progress. --> - + From 153467d8c3b1a4916f9b06c96b43776082d16a8b Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:17:28 +0100 Subject: [PATCH 50/54] build(lint): enable LogConditional in JVM library modules lint-release.xml enables this default-disabled issue, but JVM modules do not apply lint-release.xml. Once :AnkiDroid lints with checkDependencies = true, an issue enabled in the app must not be hidden in a dependency module (CannotEnableHidden). Part of 21500 Assisted-by: Claude Fable 5 --- .../main/kotlin/ankidroid.jvm.library.gradle.kts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts index 2aac16923ae9..8f519ac9dbcf 100644 --- a/buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts +++ b/buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts @@ -27,4 +27,17 @@ if (path != ":lint-rules") { dependencies { "lintChecks"(project(":lint-rules")) } + configure { + // Workaround for internal lint rule: `CannotEnableHidden` + // we want to enable this in `:AnkiDroid` with `checkDependencies = true` + + // > Any issues that are specifically disabled in a library cannot be + // > re-enabled in a dependent project. To fix this you need to also + // > enable the issue in the library project. + // + // > (This also applies for issues that are off by default; they cannot + // > just be enabled in a dependent project; they must also be enabled + // > in all the libraries the project depends on.) + enable += "LogConditional" + } } From dcffec1c96a35cfa71562ba8d36b2374c58d4754 Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 24 Sep 2026 10:54:24 +0200 Subject: [PATCH 51/54] Review ANKIQUEST_RELEASE for crash reports Upstream's AcraBuildConfigTest requires every BuildConfig field sent with crash reports to be listed. ANKIQUEST_RELEASE is the quest release number: no credentials, no personal data. --- AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt index 893174b28dc0..08073c789211 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AcraBuildConfigTest.kt @@ -33,6 +33,7 @@ class AcraBuildConfigTest { buildConfig.keys().asSequence().toList(), containsInAnyOrder( "ACRA_URL", + "ANKIQUEST_RELEASE", "APPLICATION_ID", "BACKEND_VERSION", "BUILD_TIME", From d39ca596ef1720c506a38863dcbaef454caa89da Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:59:30 +0100 Subject: [PATCH 52/54] feat(coroutines): list caller in stack trace `recordCoroutineCaller` adds the caller to stack traces of suspend functions. For understanding stack traces which fail inside `withCol` Assisted-by: GPT-6 --- common/build.gradle.kts | 1 + .../coroutines/RecordCoroutineCaller.kt | 33 ++++++++++ .../coroutines/RecordCoroutineCallerTest.kt | 61 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 common/src/main/java/com/ichi2/anki/common/coroutines/RecordCoroutineCaller.kt create mode 100644 common/src/test/java/com/ichi2/anki/common/coroutines/RecordCoroutineCallerTest.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 1f8841f5b28c..1b2d92d57c91 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { testImplementation(libs.hamcrest) testImplementation(libs.junit.platform.launcher) testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) testFixturesImplementation(libs.hamcrest) testFixturesImplementation(libs.androidx.annotation) diff --git a/common/src/main/java/com/ichi2/anki/common/coroutines/RecordCoroutineCaller.kt b/common/src/main/java/com/ichi2/anki/common/coroutines/RecordCoroutineCaller.kt new file mode 100644 index 000000000000..4f9b845a4866 --- /dev/null +++ b/common/src/main/java/com/ichi2/anki/common/coroutines/RecordCoroutineCaller.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.common.coroutines + +import kotlin.coroutines.cancellation.CancellationException + +/** + * Adds the call site to exceptions raised in [block]. + * + * In helpers (`withCol`), the helpers should be made `inline`, so it traces the caller and not + * the helper. + * + * **Usage** + * ```kotlin + * suspend inline fun readDecksWithTrace() = recordCoroutineCaller { + * withContext(Dispatchers.IO) { readDecks() } + * } + * ``` + * + * This is added as a [suppressed call site][Exception.addSuppressed]. + * + * Do not depend on this to add the full call stack, only the immediate caller. + */ +@Suppress("RedundantSuspendModifier") // Intentionally restricted to suspending contexts. +suspend inline fun recordCoroutineCaller(block: () -> T): T = + try { + block() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + exception.addSuppressed(Exception("Coroutine call site")) + throw exception + } diff --git a/common/src/test/java/com/ichi2/anki/common/coroutines/RecordCoroutineCallerTest.kt b/common/src/test/java/com/ichi2/anki/common/coroutines/RecordCoroutineCallerTest.kt new file mode 100644 index 000000000000..b2d4984e2925 --- /dev/null +++ b/common/src/test/java/com/ichi2/anki/common/coroutines/RecordCoroutineCallerTest.kt @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.common.coroutines + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Tests for [recordCoroutineCaller] */ +class RecordCoroutineCallerTest { + @Test + fun `failure records the caller while preserving the original exception`() = + runTest { + val failure = OperationFailure(123) + val suppressed = IllegalArgumentException("original diagnostic") + failure.addSuppressed(suppressed) + val originalStack = failure.stackTrace.toList() + + val caught = assertFailsWith { requestOperation(failure) } + + assertSame(failure, caught) + assertEquals(originalStack, caught.stackTrace.toList()) + assertEquals(2, caught.suppressed.size) + assertSame(suppressed, caught.suppressed.first()) + val caller = caught.suppressed.last() + assertEquals("Coroutine call site", caller.message) + assertTrue(caller.stackTrace.any { it.methodName == "requestOperation" }) + } + + @Test + fun `cancellation is unchanged`() = + runTest { + val cancellation = CancellationException("cancelled") + + val caught = + assertFailsWith { + recordCoroutineCaller { throw cancellation } + } + + assertSame(cancellation, caught) + assertTrue(caught.suppressed.isEmpty()) + } + + private suspend fun requestOperation(failure: Exception): Nothing = + recordCoroutineCaller { + withContext(Dispatchers.Default) { throw failure } + } + + // This suppresses a helper from kotlinx.coroutines (it doesn't work on exceptions with state), + // if built-in recovery won't work, then we're asserting on our code and not the framework + @Suppress("unused") + private class OperationFailure( + val code: Int, + ) : Exception("operation failed") +} From d13b7cece13518593549a71eb35d34c147de32f7 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:59:40 +0100 Subject: [PATCH 53/54] logs(collection): log `withCol` callers in exceptions Part of Issue 21979. --- .../anki/CollectionManagerStackTraceTest.kt | 37 +++++++++++++++++++ .../com/ichi2/anki/CollectionManager.kt | 18 ++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt new file mode 100644 index 000000000000..8c38dca3ffc9 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.shadows.ShadowBuild +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class CollectionManagerStackTraceTest : RobolectricTest() { + @Test + fun `opening failure records the withCol caller`() = + runTest { + val originalFingerprint = Build.FINGERPRINT + // Exercise the real dispatcher switch: Robolectric's synchronous queue retains the caller. + ShadowBuild.setFingerprint("collection-stack-trace-test") + try { + CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED + + val failure = assertFailsWith { requestCollection() } + + assertFalse(failure.stackTrace.any { it.methodName == "requestCollection" }) + val caller = failure.suppressed.single() + assertTrue(caller.stackTrace.any { it.methodName == "requestCollection" }) + } finally { + ShadowBuild.setFingerprint(originalFingerprint) + } + } + + private suspend fun requestCollection(): Nothing = CollectionManager.withCol { error("opening should fail first") } +} diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt index cc80d0bfa770..ff0979b888b2 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt @@ -20,6 +20,7 @@ import com.ichi2.anki.CollectionManager.withQueue import com.ichi2.anki.backend.createDatabaseUsingRustBackend import com.ichi2.anki.common.android.ApplicationContextInitializer import com.ichi2.anki.common.android.appContext +import com.ichi2.anki.common.coroutines.recordCoroutineCaller import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.storage.StorageDecision @@ -143,7 +144,22 @@ object CollectionManager { * @throws SystemStorageException if startup failed to choose a default collection path * ([CollectionHelper.systemStorageFailure]) */ - suspend fun withCol( + suspend inline fun withCol( + @WorkerThread noinline block: Collection.() -> T, + ): T = + // this method must be inline for recordCoroutineCaller + recordCoroutineCaller { + @Suppress("DEPRECATION_ERROR") + withColInternal(block) + } + + /** Published only for inlined [withCol] calls; direct calls would bypass caller tracing. */ + @PublishedApi + @Deprecated( + message = "Call withCol instead.", + level = DeprecationLevel.ERROR, + ) + internal suspend fun withColInternal( @WorkerThread block: Collection.() -> T, ): T = withQueue { From ffc6439f68d03c454825ae0d9c627dc8de170e85 Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 24 Sep 2026 11:46:59 +0200 Subject: [PATCH 54/54] ci: sync upstream AnkiDroid daily Fast-forward main to ankidroid/Anki-Android main every morning and open a pull request from main into ankiquest when ankiquest is behind. main stays a mirror; the job fails rather than push over local commits. Needs UPSTREAM_SYNC_TOKEN: GITHUB_TOKEN cannot push commits that change upstream's workflows, and pull requests it opens start no CI. --- .github/workflows/sync_upstream.yml | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/sync_upstream.yml diff --git a/.github/workflows/sync_upstream.yml b/.github/workflows/sync_upstream.yml new file mode 100644 index 000000000000..394cfb211fe7 --- /dev/null +++ b/.github/workflows/sync_upstream.yml @@ -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."