From 17565b99e23a0101568acd74d3b526d24d4f537c Mon Sep 17 00:00:00 2001 From: MorenoTropical <154519856+morenotropical@users.noreply.github.com> Date: Sun, 21 Jul 2024 17:46:40 -0300 Subject: [PATCH 01/10] improve deleted deck message --- AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index b737375019d1..e097bc0a2f97 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -2327,6 +2327,7 @@ open class DeckPicker : */ fun deleteDeck(did: DeckId): Job { return launchCatchingTask { + val deckName = withCol { decks.get(did)!!.name } val changes = withProgress(resources.getString(R.string.delete_deck)) { undoableOp { decks.remove(listOf(did)) @@ -2335,7 +2336,7 @@ open class DeckPicker : // After deletion: decks.current() reverts to Default, necessitating `focusedDeck` // to match and avoid unnecessary scrolls in `renderPage()`. focusedDeck = Consts.DEFAULT_DECK_ID - showSnackbar(TR.browsingCardsDeleted(changes.count), Snackbar.LENGTH_SHORT) { + showSnackbar(TR.browsingCardsDeletedWithDeckname(changes.count, deckName), Snackbar.LENGTH_SHORT) { setAction(R.string.undo) { undo() } } } From b9aacdee3337599833e9d04bb1995746e907eabd Mon Sep 17 00:00:00 2001 From: SanjaySargam Date: Wed, 17 Jul 2024 22:48:58 +0530 Subject: [PATCH 02/10] Unify imagePath and audioPath into a single mediaPath --- .../src/main/java/com/ichi2/anki/NoteEditor.kt | 4 ++-- .../anki/multimedia/MultimediaImageFragment.kt | 2 +- .../activity/MultimediaEditFieldActivity.kt | 18 ++++-------------- .../anki/multimediacard/fields/AudioField.kt | 8 +++----- .../fields/BasicImageFieldController.kt | 10 +++++----- .../fields/BasicMediaClipFieldController.kt | 6 +++--- .../ichi2/anki/multimediacard/fields/IField.kt | 9 ++------- .../anki/multimediacard/fields/ImageField.kt | 6 ++---- .../anki/multimediacard/fields/TextField.kt | 4 +--- .../com/ichi2/anki/servicelayer/NoteService.kt | 6 ++---- .../ichi2/audio/AudioRecordingController.kt | 4 ++-- .../fields/BasicImageFieldControllerTest.kt | 2 +- .../multimediacard/fields/MediaClipTest.kt | 2 +- .../com/ichi2/anki/services/NoteServiceTest.kt | 18 +++++++++--------- 14 files changed, 38 insertions(+), 61 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt index e7dda02d8c46..f9bfad2230cd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt @@ -294,7 +294,7 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su val index = extras.getInt(MultimediaEditFieldActivity.EXTRA_RESULT_FIELD_INDEX) val field = extras.getSerializableCompat(MultimediaEditFieldActivity.EXTRA_RESULT_FIELD) ?: return@NoteEditorActivityResultCallback - if (field.type != EFieldType.TEXT && (field.imagePath == null && field.audioPath == null)) { + if (field.type != EFieldType.TEXT && (field.mediaPath == null)) { Timber.i("field imagePath and audioPath are both null") return@NoteEditorActivityResultCallback } @@ -1788,7 +1788,7 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su ?: return // Process successful result only if field has data - if (field.type != EFieldType.TEXT || field.imagePath != null || field.audioPath != null) { + if (field.type != EFieldType.TEXT || field.mediaPath != null) { addMediaFileToField(index, field) } else { Timber.i("field imagePath and audioPath are both 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 cfb6df9ad023..d2d06897f1b2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt @@ -142,7 +142,7 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ return@setOnClickListener } - field.imagePath = viewModel.currentImagePath + field.mediaPath = viewModel.currentImagePath field.hasTemporaryMedia = true val resultData = Intent().apply { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/activity/MultimediaEditFieldActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/activity/MultimediaEditFieldActivity.kt index 6809a195e543..1bccad78bb24 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/activity/MultimediaEditFieldActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/activity/MultimediaEditFieldActivity.kt @@ -249,15 +249,15 @@ class MultimediaEditFieldActivity : @KotlinCleanup("rename: bChangeToText") private fun done() { var bChangeToText = false - if (field.type === EFieldType.IMAGE) { - if (field.imagePath == null) { + if (field.type === EFieldType.IMAGE || (field.type === EFieldType.AUDIO_RECORDING && isAudioRecordingSaved)) { + if (field.mediaPath == null) { bChangeToText = true } if (!bChangeToText) { - val f = File(field.imagePath!!) + val f = File(field.mediaPath!!) if (!f.exists()) { bChangeToText = true - } else { + } else if (field.type === EFieldType.IMAGE) { val length = f.length() if (length > IMAGE_LIMIT) { showLargeFileCropDialog((1.0 * length / IMAGE_LIMIT).toFloat()) @@ -265,16 +265,6 @@ class MultimediaEditFieldActivity : } } } - } else if (field.type === EFieldType.AUDIO_RECORDING && isAudioRecordingSaved) { - if (field.audioPath == null) { - bChangeToText = true - } - if (!bChangeToText) { - val f = File(field.audioPath!!) - if (!f.exists()) { - bChangeToText = true - } - } } fieldController!!.onDone() saveAndExit(bChangeToText) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioField.kt index 638abb0f4b24..1dad8136325e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/AudioField.kt @@ -31,9 +31,7 @@ import java.util.regex.Pattern abstract class AudioField : FieldBase(), IField { private var _audioPath: String? = null - override var imagePath: String? = null - - override var audioPath: String? + override var mediaPath: String? get() = _audioPath set(value) { _audioPath = value @@ -45,7 +43,7 @@ abstract class AudioField : FieldBase(), IField { override var hasTemporaryMedia: Boolean = false override val formattedValue: String - get() = audioPath?.let { path -> + get() = mediaPath?.let { path -> val file = File(path) if (file.exists()) "[sound:${file.name}]" else "" } ?: "" @@ -58,7 +56,7 @@ abstract class AudioField : FieldBase(), IField { res = m.group(1)!! } val mediaDir = col.media.dir + "/" - audioPath = mediaDir + res + mediaPath = mediaDir + res } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldController.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldController.kt index 0dbd7ccb3646..80079a718264 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldController.kt @@ -404,7 +404,7 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController { private fun revertToPreviousImage() { viewModel.deleteImagePath() viewModel = ImageViewModel(previousImagePath, previousImageUri) - _field.imagePath = previousImagePath + _field.mediaPath = previousImagePath previousImagePath = null previousImageUri = null } @@ -575,7 +575,7 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController { Timber.w("rotateAndCompress() delete of pre-compressed image failed %s", imagePath) } viewModel = imageViewModel.rotateAndCompressTo(outFile.absolutePath, getUriForFile(outFile)) - _field.imagePath = outFile.absolutePath + _field.mediaPath = outFile.absolutePath Timber.d("rotateAndCompress out path %s has size %d", outFile.absolutePath, outFile.length()) } catch (e: FileNotFoundException) { Timber.w(e, "rotateAndCompress() File not found for image compression %s", imagePath) @@ -700,7 +700,7 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController { private fun setTemporaryMedia(imagePath: String) { _field.apply { - this.imagePath = imagePath + this.mediaPath = imagePath hasTemporaryMedia = true } } @@ -730,7 +730,7 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController { Timber.i("handleCropResult() appears to have an invalid file, reverting") return } - Timber.d("handleCropResult() = image path now %s", _field.imagePath) + Timber.d("handleCropResult() = image path now %s", _field.mediaPath) } private fun rotateAndCompress(): Boolean { @@ -864,7 +864,7 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController { var newImagePath = imagePath var newImageUri = imageUri if (newImagePath == null) { - newImagePath = field.imagePath + newImagePath = field.mediaPath } if (newImageUri == null && newImagePath != null) { newImageUri = getUriForFile(File(newImagePath), context) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicMediaClipFieldController.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicMediaClipFieldController.kt index 499eda9780f3..13bd843b5770 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicMediaClipFieldController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicMediaClipFieldController.kt @@ -73,10 +73,10 @@ class BasicMediaClipFieldController : FieldControllerBase(), IFieldController { } layout.addView(btnVideo, ViewGroup.LayoutParams.MATCH_PARENT) tvAudioClip = FixedTextView(_activity) - if (_field.audioPath == null) { + if (_field.mediaPath == null) { tvAudioClip.visibility = View.GONE } else { - tvAudioClip.text = _field.audioPath + tvAudioClip.text = _field.mediaPath tvAudioClip.visibility = View.VISIBLE } layout.addView(tvAudioClip, ViewGroup.LayoutParams.MATCH_PARENT) @@ -181,7 +181,7 @@ class BasicMediaClipFieldController : FieldControllerBase(), IFieldController { // If everything worked, hand off the information _field.hasTemporaryMedia = true - _field.audioPath = clipCopy.absolutePath + _field.mediaPath = clipCopy.absolutePath tvAudioClip.text = clipCopy.name tvAudioClip.visibility = View.VISIBLE } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/IField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/IField.kt index 6e263756c794..546c206878fb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/IField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/IField.kt @@ -30,13 +30,8 @@ interface IField : Serializable { val isModified: Boolean - // For image type. Resets type. - // Makes no sense to call when type is not image. - // the same for other groups below. - var imagePath: String? - - // For Audio type - var audioPath: String? + // Path of the folder containing media used by AnkiDroid. + var mediaPath: String? // For Text type var text: String? diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/ImageField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/ImageField.kt index 81e10aa3c9ce..6bb7d3407abc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/ImageField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/ImageField.kt @@ -43,15 +43,13 @@ class ImageField : FieldBase(), IField { override val isModified: Boolean get() = thisModified - override var imagePath: String? + override var mediaPath: String? get() = extraImagePathRef set(value) { extraImagePathRef = value setThisModified() } - override var audioPath: String? = null - override var text: String? = null override var hasTemporaryMedia: Boolean = false @@ -64,7 +62,7 @@ class ImageField : FieldBase(), IField { override val formattedValue: String get() { - val file = File(imagePath!!) + val file = File(mediaPath!!) return formatImageFileName(file) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/TextField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/TextField.kt index 352746379b03..60b198599dc7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/TextField.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/TextField.kt @@ -33,9 +33,7 @@ class TextField : FieldBase(), IField { override val isModified: Boolean get() = thisModified - override var imagePath: String? = null - - override var audioPath: String? = null + override var mediaPath: String? = null override var text: String? get() = _text diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt index 32dff4efb7cd..bc399721ce0b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt @@ -130,8 +130,7 @@ object NoteService { fun importMediaToDirectory(col: Collection, field: IField?) { var tmpMediaPath: String? = null when (field!!.type) { - EFieldType.AUDIO_RECORDING, EFieldType.MEDIA_CLIP -> tmpMediaPath = field.audioPath - EFieldType.IMAGE -> tmpMediaPath = field.imagePath + EFieldType.AUDIO_RECORDING, EFieldType.MEDIA_CLIP, EFieldType.IMAGE -> tmpMediaPath = field.mediaPath EFieldType.TEXT -> { } } @@ -147,8 +146,7 @@ object NoteService { inFile.delete() } when (field.type) { - EFieldType.AUDIO_RECORDING, EFieldType.MEDIA_CLIP -> field.audioPath = outFile.absolutePath - EFieldType.IMAGE -> field.imagePath = outFile.absolutePath + EFieldType.AUDIO_RECORDING, EFieldType.MEDIA_CLIP, EFieldType.IMAGE -> field.mediaPath = outFile.absolutePath else -> { } } diff --git a/AnkiDroid/src/main/java/com/ichi2/audio/AudioRecordingController.kt b/AnkiDroid/src/main/java/com/ichi2/audio/AudioRecordingController.kt index 78b0358a9df9..2272bd2ed7a7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/audio/AudioRecordingController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/audio/AudioRecordingController.kt @@ -113,7 +113,7 @@ class AudioRecordingController : this.state = initialState audioRecorder = AudioRecorder() if (inEditField) { - val origAudioPath = this._field.audioPath + val origAudioPath = this._field.mediaPath var bExist = false if (origAudioPath != null) { val f = File(origAudioPath) @@ -583,7 +583,7 @@ class AudioRecordingController : } private fun saveRecording() { - _field.audioPath = tempAudioPath + _field.mediaPath = tempAudioPath _field.hasTemporaryMedia = true } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldControllerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldControllerTest.kt index 409e5bba49fd..a5af5b835fb1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldControllerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldControllerTest.kt @@ -134,7 +134,7 @@ open class BasicImageFieldControllerTest : MultimediaEditFieldActivityTestBase() private fun imageFieldWithData(): IField { val field = emptyImageField() - field.imagePath = targetContext.cacheDir.toString() + "/temp-photos/test" + field.mediaPath = targetContext.cacheDir.toString() + "/temp-photos/test" return field } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimediacard/fields/MediaClipTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimediacard/fields/MediaClipTest.kt index d5eadc5e3389..1e43057fa53c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multimediacard/fields/MediaClipTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimediacard/fields/MediaClipTest.kt @@ -35,7 +35,7 @@ class MediaClipTest { it.createNewFile() it.deleteOnExit() } - mediaClipField.audioPath = mediaFile.path + mediaClipField.mediaPath = mediaFile.path assertThat(mediaClipField.formattedValue, equalTo("[sound:foo]")) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/services/NoteServiceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/services/NoteServiceTest.kt index befa570fbd76..3d27b276416e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/services/NoteServiceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/services/NoteServiceTest.kt @@ -95,13 +95,13 @@ class NoteServiceTest : RobolectricTest() { FileWriter(fileAudio).use { fileWriter -> fileWriter.write("line1") } val audioField = MediaClipField() - audioField.audioPath = fileAudio.absolutePath + audioField.mediaPath = fileAudio.absolutePath NoteService.importMediaToDirectory(col, audioField) val outFile = File(col.media.dir, fileAudio.name) - assertThat("path should be equal to new file made in NoteService.importMediaToDirectory", outFile, aFileWithAbsolutePath(equalTo(audioField.audioPath))) + assertThat("path should be equal to new file made in NoteService.importMediaToDirectory", outFile, aFileWithAbsolutePath(equalTo(audioField.mediaPath))) } // Similar test like above, but with an ImageField instead of a MediaClipField @@ -144,14 +144,14 @@ class NoteServiceTest : RobolectricTest() { FileWriter(f2).use { fileWriter -> fileWriter.write("2") } val fld1 = MediaClipField() - fld1.audioPath = f1.absolutePath + fld1.mediaPath = f1.absolutePath val fld2 = MediaClipField() - fld2.audioPath = f2.absolutePath + fld2.mediaPath = f2.absolutePath // third field to test if name is kept after reimporting the same file val fld3 = MediaClipField() - fld3.audioPath = f1.absolutePath + fld3.mediaPath = f1.absolutePath Timber.e("media folder is %s %b", col.media.dir, File(col.media.dir).exists()) NoteService.importMediaToDirectory(col, fld1) @@ -163,9 +163,9 @@ class NoteServiceTest : RobolectricTest() { NoteService.importMediaToDirectory(col, fld3) // creating a third outfile isn't necessary because it should be equal to the first one - assertThat("path should be equal to new file made in NoteService.importMediaToDirectory", o1, aFileWithAbsolutePath(equalTo(fld1.audioPath))) - assertThat("path should be different to new file made in NoteService.importMediaToDirectory", o2, aFileWithAbsolutePath(not(fld2.audioPath))) - assertThat("path should be equal to new file made in NoteService.importMediaToDirectory", o1, aFileWithAbsolutePath(equalTo(fld3.audioPath))) + assertThat("path should be equal to new file made in NoteService.importMediaToDirectory", o1, aFileWithAbsolutePath(equalTo(fld1.mediaPath))) + assertThat("path should be different to new file made in NoteService.importMediaToDirectory", o2, aFileWithAbsolutePath(not(fld2.mediaPath))) + assertThat("path should be equal to new file made in NoteService.importMediaToDirectory", o1, aFileWithAbsolutePath(equalTo(fld3.mediaPath))) } // Similar test like above, but with an ImageField instead of a MediaClipField @@ -214,7 +214,7 @@ class NoteServiceTest : RobolectricTest() { val file = createTransientFile("foo") val field = MediaClipField().apply { - audioPath = file.absolutePath + mediaPath = file.absolutePath hasTemporaryMedia = true } From ece272e7afa042f069a15c925d16765b93a734ad Mon Sep 17 00:00:00 2001 From: snowtimeglass Date: Sun, 23 Jun 2024 14:20:42 +0900 Subject: [PATCH 03/10] Make "Gallery" labels translatable from Crowdin Use the existing "Gallery" string which is translatable in Crowdin for: - the "Gallery" option label in the menu of "Select image" in "Image Occlusion" note type - the "Gallery" option label in the menu of multimedia file attachment in the note editor --- AnkiDroid/src/main/res/layout/bottomsheet_multimedia.xml | 2 +- .../src/main/res/layout/image_occlusion_options_layout.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/res/layout/bottomsheet_multimedia.xml b/AnkiDroid/src/main/res/layout/bottomsheet_multimedia.xml index 1c61e64d4fba..eb4fb0d3b079 100644 --- a/AnkiDroid/src/main/res/layout/bottomsheet_multimedia.xml +++ b/AnkiDroid/src/main/res/layout/bottomsheet_multimedia.xml @@ -45,7 +45,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center_vertical" - android:text="@string/pick_image_gallery" + android:text="@string/multimedia_editor_image_field_editing_galery" app:iconPadding="12dp" /> diff --git a/AnkiDroid/src/main/res/layout/image_occlusion_options_layout.xml b/AnkiDroid/src/main/res/layout/image_occlusion_options_layout.xml index 8dc5de4410ae..9baafb05095d 100644 --- a/AnkiDroid/src/main/res/layout/image_occlusion_options_layout.xml +++ b/AnkiDroid/src/main/res/layout/image_occlusion_options_layout.xml @@ -67,7 +67,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center_vertical" - android:text="@string/pick_image_gallery" /> + android:text="@string/multimedia_editor_image_field_editing_galery" /> \ No newline at end of file From 1feb8b31b863ed8d0f305e3db48c72332fd65792 Mon Sep 17 00:00:00 2001 From: CLIDragon <84266961+CLIDragon@users.noreply.github.com> Date: Mon, 8 Jul 2024 11:36:47 +1000 Subject: [PATCH 04/10] Prevent CRLF line endings * Set .gitattributes to automatically detect text files, enabling git to remove CRLF. * Keep CRLF for .bat files * Use .editorconfig to avoid further introduction of CRLF --- .editorconfig | 5 +++++ .gitattributes | 50 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/.editorconfig b/.editorconfig index e69de29bb2d1..90abd91b3a53 100644 --- a/.editorconfig +++ b/.editorconfig @@ -0,0 +1,5 @@ +[*] +end_of_line = lf + +[*.bat] +end_of_line = crlf \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index b8e87403f1fc..e4d6260f89b7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,52 @@ -*.bat text -*.csv text +# For each file not matching a pattern listed below, let git automatically +# detect whether it's a text file. If it is, use LF end of lines. +# Last match resolves, so we place the catch-all at the top of the file. +* text=auto eol=lf + +# Text file whose eol is not specified defaults to LF. We only need to +# explicitly specify when we want CRLF. +*.awk text +*.bat text eol=crlf *.css text +*.csv text +*.gradle text *.html text *.java text +*.js text +*.json text +*.kt text +*.kts text +*.md text +*.py text +*.sh text +*.sql text +*.toml text +*.ts text *.txt text *.xml text -.idea/dictionaries/*.xml linguist-generated=false +*.yaml text +*.yml text + +# Files we want to diff, but not change the line endings. +*.anki2 -text +*.crt -text +*.svg -text + +# Binary and compiled files. We want to neither diff nor alter these files. +*.apkg binary +*.gz binary +*.jar binary +*.mp4 binary +*.mwb binary +*.png binary +*.psd binary +*.tgz binary +*.ttf binary +*.webm binary +*.woff binary +*.xcf binary +*.zip binary + +# Tell GitHub that this file is not auto-generated, and should be displayed +# in diffs. +.idea/dictionaries/*.xml linguist-generated=false \ No newline at end of file From 2c7c0c5bda919c5a0a770a01a5bb31b5ae14e6fa Mon Sep 17 00:00:00 2001 From: CLIDragon <84266961+CLIDragon@users.noreply.github.com> Date: Mon, 15 Jul 2024 17:11:59 +1000 Subject: [PATCH 05/10] Replace CRLF with LF --- .../java/com/ichi2/anki/ReviewerTest.kt | 416 +++++++++--------- .../com/ichi2/anki/pages/ImageOcclusion.kt | 198 ++++----- .../AccessibilitySettingsFragment.kt | 62 +-- .../com/ichi2/anki/ConstantUniquenessTest.kt | 110 ++--- .../anki/previewer/PreviewerViewModelTest.kt | 60 +-- jitpack.yml | 30 +- 6 files changed, 438 insertions(+), 438 deletions(-) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt index 185a64bddd59..00734356c43e 100755 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt @@ -1,208 +1,208 @@ -/* - * Copyright (c) 2023 - * - * 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.recyclerview.widget.RecyclerView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.NoMatchingViewException -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.hasDescendant -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withResourceName -import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.tests.InstrumentedTest -import com.ichi2.anki.tests.libanki.RetryRule -import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission -import com.ichi2.anki.testutil.ThreadUtils -import com.ichi2.anki.testutil.grantPermissions -import com.ichi2.anki.testutil.notificationPermission -import com.ichi2.libanki.Collection -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import java.lang.AssertionError - -@RunWith(AndroidJUnit4::class) -class ReviewerTest : InstrumentedTest() { - - // Launch IntroductionActivity instead of DeckPicker activity because in CI - // builds, it seems to create IntroductionActivity after the DeckPicker, - // causing the DeckPicker activity to be destroyed. As a consequence, this - // will throw RootViewWithoutFocusException when Espresso tries to interact - // with an already destroyed activity. By launching IntroductionActivity, we - // ensure that IntroductionActivity is launched first and navigate to the - // DeckPicker -> Reviewer activities - @get:Rule - val activityScenarioRule = ActivityScenarioRule(IntroductionActivity::class.java) - - @get:Rule - val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission) - - @get:Rule - val retry = RetryRule(10) - - @Test - fun testCustomSchedulerWithCustomData() { - col.cardStateCustomizer = - """ - states.good.normal.review.easeFactor = 3.0; - states.good.normal.review.scheduledDays = 123; - customData.good.c += 1; - """ - val note = addNoteUsingBasicModel("foo", "bar") - val card = note.firstCard(col) - val deck = col.decks.get(note.notetype.did)!! - card.moveToReviewQueue() - col.backend.updateCards( - listOf( - card.toBackendCard().toBuilder().setCustomData("""{"c":1}""").build() - ), - true - ) - - closeGetStartedScreenIfExists() - closeBackupCollectionDialogIfExists() - reviewDeckWithName(deck.name) - - var cardFromDb = col.getCard(card.id).toBackendCard() - assertThat(cardFromDb.easeFactor, equalTo(card.factor)) - assertThat(cardFromDb.interval, equalTo(card.ivl)) - assertThat(cardFromDb.customData, equalTo("""{"c":1}""")) - - clickShowAnswerAndAnswerGood() - - fun runAssertion() { - cardFromDb = col.getCard(card.id).toBackendCard() - assertThat(cardFromDb.easeFactor, equalTo(3000)) - assertThat(cardFromDb.interval, equalTo(123)) - assertThat(cardFromDb.customData, equalTo("""{"c":2}""")) - } - - try { - runAssertion() - } catch (e: Exception) { - // Give separate threads a greater chance of doing the custom scheduling - // if the card scheduling values aren't updated immediately - ThreadUtils.sleep(2000) - runAssertion() - } - } - - @Test - fun testCustomSchedulerWithRuntimeError() { - // Issue 15035 - runtime errors weren't handled - col.cardStateCustomizer = "states.this_is_not_defined.normal.review = 12;" - addNoteUsingBasicModel() - - closeGetStartedScreenIfExists() - closeBackupCollectionDialogIfExists() - reviewDeckWithName("Default") - - clickShowAnswer() - - ensureAnswerButtonsAreDisplayed() - } - - private fun clickOnDeckWithName(deckName: String) { - onView(withId(R.id.files)).checkWithTimeout(matches(hasDescendant(withText(deckName)))) - onView(withId(R.id.files)).perform( - RecyclerViewActions.actionOnItem( - hasDescendant(withText(deckName)), - click() - ) - ) - } - - private fun clickOnStudyButtonIfExists() { - onView(withId(R.id.studyoptions_start)) - .withFailureHandler { _, _ -> } - .perform(click()) - } - - private fun reviewDeckWithName(deckName: String) { - clickOnDeckWithName(deckName) - // Adding cards directly to the database while in the Deck Picker screen - // will not update the page with correct card counts. Hence, clicking - // on the deck will bring us to the study options page where we need to - // click on the Study button. If we have added cards to the database - // before the Deck Picker screen has fully loaded, then we skip clicking - // the Study button - clickOnStudyButtonIfExists() - } - - private fun clickShowAnswerAndAnswerGood() { - clickShowAnswer() - ensureAnswerButtonsAreDisplayed() - try { - // ...on the command line it has resource name "good_button"... - onView(withResourceName("good_button")).perform(click()) - } catch (e: NoMatchingViewException) { - // ...but in Android Studio it has resource name "flashcard_layout_ease3" !? - onView(withResourceName("flashcard_layout_ease3")).perform(click()) - } - } - - private fun clickShowAnswer() { - try { - // ... on the command line, it has resource name "show_answer"... - onView(withResourceName("show_answer")).perform(click()) - } catch (e: NoMatchingViewException) { - // ... but in Android Studio it has resource name "flashcard_layout_flip" !? - onView(withResourceName("flashcard_layout_flip")).perform(click()) - } - } - - private fun ensureAnswerButtonsAreDisplayed() { - // We need to wait for the card to fully load to allow enough time for - // the messages to be passed in and out of the WebView when evaluating - // the custom JS scheduler code. The ease buttons are hidden until the - // custom scheduler has finished running - try { - // ...on the command line it has resource name "good_button"... - onView(withResourceName("good_button")).checkWithTimeout( - matches(isDisplayed()), - 100 - ) - } catch (e: AssertionError) { - // ...but in Android Studio it has resource name "flashcard_layout_ease3" !? - onView(withResourceName("flashcard_layout_ease3")).checkWithTimeout( - matches(isDisplayed()), - 100 - ) - } - } -} - -private var Collection.cardStateCustomizer: String? - get() = config.get("cardStateCustomizer") - set(value) { config.set("cardStateCustomizer", value) } - -fun closeGetStartedScreenIfExists() { - onView(withId(R.id.get_started)).withFailureHandler { _, _ -> }.perform(click()) -} - -fun closeBackupCollectionDialogIfExists() { - onView(withText(R.string.button_backup_later)) - .withFailureHandler { _, _ -> } - .perform(click()) -} +/* + * Copyright (c) 2023 + * + * 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.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withResourceName +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.tests.InstrumentedTest +import com.ichi2.anki.tests.libanki.RetryRule +import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission +import com.ichi2.anki.testutil.ThreadUtils +import com.ichi2.anki.testutil.grantPermissions +import com.ichi2.anki.testutil.notificationPermission +import com.ichi2.libanki.Collection +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.AssertionError + +@RunWith(AndroidJUnit4::class) +class ReviewerTest : InstrumentedTest() { + + // Launch IntroductionActivity instead of DeckPicker activity because in CI + // builds, it seems to create IntroductionActivity after the DeckPicker, + // causing the DeckPicker activity to be destroyed. As a consequence, this + // will throw RootViewWithoutFocusException when Espresso tries to interact + // with an already destroyed activity. By launching IntroductionActivity, we + // ensure that IntroductionActivity is launched first and navigate to the + // DeckPicker -> Reviewer activities + @get:Rule + val activityScenarioRule = ActivityScenarioRule(IntroductionActivity::class.java) + + @get:Rule + val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission) + + @get:Rule + val retry = RetryRule(10) + + @Test + fun testCustomSchedulerWithCustomData() { + col.cardStateCustomizer = + """ + states.good.normal.review.easeFactor = 3.0; + states.good.normal.review.scheduledDays = 123; + customData.good.c += 1; + """ + val note = addNoteUsingBasicModel("foo", "bar") + val card = note.firstCard(col) + val deck = col.decks.get(note.notetype.did)!! + card.moveToReviewQueue() + col.backend.updateCards( + listOf( + card.toBackendCard().toBuilder().setCustomData("""{"c":1}""").build() + ), + true + ) + + closeGetStartedScreenIfExists() + closeBackupCollectionDialogIfExists() + reviewDeckWithName(deck.name) + + var cardFromDb = col.getCard(card.id).toBackendCard() + assertThat(cardFromDb.easeFactor, equalTo(card.factor)) + assertThat(cardFromDb.interval, equalTo(card.ivl)) + assertThat(cardFromDb.customData, equalTo("""{"c":1}""")) + + clickShowAnswerAndAnswerGood() + + fun runAssertion() { + cardFromDb = col.getCard(card.id).toBackendCard() + assertThat(cardFromDb.easeFactor, equalTo(3000)) + assertThat(cardFromDb.interval, equalTo(123)) + assertThat(cardFromDb.customData, equalTo("""{"c":2}""")) + } + + try { + runAssertion() + } catch (e: Exception) { + // Give separate threads a greater chance of doing the custom scheduling + // if the card scheduling values aren't updated immediately + ThreadUtils.sleep(2000) + runAssertion() + } + } + + @Test + fun testCustomSchedulerWithRuntimeError() { + // Issue 15035 - runtime errors weren't handled + col.cardStateCustomizer = "states.this_is_not_defined.normal.review = 12;" + addNoteUsingBasicModel() + + closeGetStartedScreenIfExists() + closeBackupCollectionDialogIfExists() + reviewDeckWithName("Default") + + clickShowAnswer() + + ensureAnswerButtonsAreDisplayed() + } + + private fun clickOnDeckWithName(deckName: String) { + onView(withId(R.id.files)).checkWithTimeout(matches(hasDescendant(withText(deckName)))) + onView(withId(R.id.files)).perform( + RecyclerViewActions.actionOnItem( + hasDescendant(withText(deckName)), + click() + ) + ) + } + + private fun clickOnStudyButtonIfExists() { + onView(withId(R.id.studyoptions_start)) + .withFailureHandler { _, _ -> } + .perform(click()) + } + + private fun reviewDeckWithName(deckName: String) { + clickOnDeckWithName(deckName) + // Adding cards directly to the database while in the Deck Picker screen + // will not update the page with correct card counts. Hence, clicking + // on the deck will bring us to the study options page where we need to + // click on the Study button. If we have added cards to the database + // before the Deck Picker screen has fully loaded, then we skip clicking + // the Study button + clickOnStudyButtonIfExists() + } + + private fun clickShowAnswerAndAnswerGood() { + clickShowAnswer() + ensureAnswerButtonsAreDisplayed() + try { + // ...on the command line it has resource name "good_button"... + onView(withResourceName("good_button")).perform(click()) + } catch (e: NoMatchingViewException) { + // ...but in Android Studio it has resource name "flashcard_layout_ease3" !? + onView(withResourceName("flashcard_layout_ease3")).perform(click()) + } + } + + private fun clickShowAnswer() { + try { + // ... on the command line, it has resource name "show_answer"... + onView(withResourceName("show_answer")).perform(click()) + } catch (e: NoMatchingViewException) { + // ... but in Android Studio it has resource name "flashcard_layout_flip" !? + onView(withResourceName("flashcard_layout_flip")).perform(click()) + } + } + + private fun ensureAnswerButtonsAreDisplayed() { + // We need to wait for the card to fully load to allow enough time for + // the messages to be passed in and out of the WebView when evaluating + // the custom JS scheduler code. The ease buttons are hidden until the + // custom scheduler has finished running + try { + // ...on the command line it has resource name "good_button"... + onView(withResourceName("good_button")).checkWithTimeout( + matches(isDisplayed()), + 100 + ) + } catch (e: AssertionError) { + // ...but in Android Studio it has resource name "flashcard_layout_ease3" !? + onView(withResourceName("flashcard_layout_ease3")).checkWithTimeout( + matches(isDisplayed()), + 100 + ) + } + } +} + +private var Collection.cardStateCustomizer: String? + get() = config.get("cardStateCustomizer") + set(value) { config.set("cardStateCustomizer", value) } + +fun closeGetStartedScreenIfExists() { + onView(withId(R.id.get_started)).withFailureHandler { _, _ -> }.perform(click()) +} + +fun closeBackupCollectionDialogIfExists() { + onView(withText(R.string.button_backup_later)) + .withFailureHandler { _, _ -> } + .perform(click()) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt index 5a92bfc4e9b6..a9e7ab27dda8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt @@ -1,99 +1,99 @@ -/* - * Copyright (c) 2023 Abdo - * - * 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.pages - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.View -import android.webkit.WebView -import androidx.activity.addCallback -import androidx.core.os.bundleOf -import com.google.android.material.appbar.MaterialToolbar -import com.ichi2.anki.R -import com.ichi2.anki.SingleFragmentActivity -import com.ichi2.anki.dialogs.DiscardChangesDialog -import org.json.JSONObject -import timber.log.Timber - -class ImageOcclusion : PageFragment(R.layout.image_occlusion) { - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - with(requireActivity()) { - onBackPressedDispatcher.addCallback(this) { - DiscardChangesDialog.showDialog(this@with) { - finish() - } - } - } - - view.findViewById(R.id.toolbar).setOnMenuItemClickListener { - if (it.itemId == R.id.action_save) { - Timber.i("save item selected") - webView.evaluateJavascript("anki.imageOcclusion.save()", null) - } - return@setOnMenuItemClickListener true - } - } - - override fun onCreateWebViewClient(savedInstanceState: Bundle?): PageWebViewClient { - return object : PageWebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - - val kind = requireArguments().getString(ARG_KEY_KIND) - val noteOrNotetypeId = requireArguments().getLong(ARG_KEY_ID) - val imagePath = requireArguments().getString(ARG_KEY_PATH) - - val options = JSONObject() - options.put("kind", kind) - if (kind == "add") { - options.put("imagePath", imagePath) - options.put("notetypeId", noteOrNotetypeId) - } else { - options.put("noteId", noteOrNotetypeId) - } - - view?.evaluateJavascript("globalThis.anki.imageOcclusion.mode = $options") { - super.onPageFinished(view, url) - } - } - } - } - - companion object { - private const val ARG_KEY_KIND = "kind" - private const val ARG_KEY_ID = "id" - private const val ARG_KEY_PATH = "imagePath" - - fun getIntent(context: Context, kind: String, noteOrNotetypeId: Long, imagePath: String?): Intent { - val suffix = if (kind == "edit") { - "/$noteOrNotetypeId" - } else { - imagePath - } - val arguments = bundleOf( - ARG_KEY_KIND to kind, - ARG_KEY_ID to noteOrNotetypeId, - ARG_KEY_PATH to imagePath, - PATH_ARG_KEY to "image-occlusion$suffix" - ) - return SingleFragmentActivity.getIntent(context, ImageOcclusion::class, arguments) - } - } -} +/* + * Copyright (c) 2023 Abdo + * + * 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.pages + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.webkit.WebView +import androidx.activity.addCallback +import androidx.core.os.bundleOf +import com.google.android.material.appbar.MaterialToolbar +import com.ichi2.anki.R +import com.ichi2.anki.SingleFragmentActivity +import com.ichi2.anki.dialogs.DiscardChangesDialog +import org.json.JSONObject +import timber.log.Timber + +class ImageOcclusion : PageFragment(R.layout.image_occlusion) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + with(requireActivity()) { + onBackPressedDispatcher.addCallback(this) { + DiscardChangesDialog.showDialog(this@with) { + finish() + } + } + } + + view.findViewById(R.id.toolbar).setOnMenuItemClickListener { + if (it.itemId == R.id.action_save) { + Timber.i("save item selected") + webView.evaluateJavascript("anki.imageOcclusion.save()", null) + } + return@setOnMenuItemClickListener true + } + } + + override fun onCreateWebViewClient(savedInstanceState: Bundle?): PageWebViewClient { + return object : PageWebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + + val kind = requireArguments().getString(ARG_KEY_KIND) + val noteOrNotetypeId = requireArguments().getLong(ARG_KEY_ID) + val imagePath = requireArguments().getString(ARG_KEY_PATH) + + val options = JSONObject() + options.put("kind", kind) + if (kind == "add") { + options.put("imagePath", imagePath) + options.put("notetypeId", noteOrNotetypeId) + } else { + options.put("noteId", noteOrNotetypeId) + } + + view?.evaluateJavascript("globalThis.anki.imageOcclusion.mode = $options") { + super.onPageFinished(view, url) + } + } + } + } + + companion object { + private const val ARG_KEY_KIND = "kind" + private const val ARG_KEY_ID = "id" + private const val ARG_KEY_PATH = "imagePath" + + fun getIntent(context: Context, kind: String, noteOrNotetypeId: Long, imagePath: String?): Intent { + val suffix = if (kind == "edit") { + "/$noteOrNotetypeId" + } else { + imagePath + } + val arguments = bundleOf( + ARG_KEY_KIND to kind, + ARG_KEY_ID to noteOrNotetypeId, + ARG_KEY_PATH to imagePath, + PATH_ARG_KEY to "image-occlusion$suffix" + ) + return SingleFragmentActivity.getIntent(context, ImageOcclusion::class, arguments) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AccessibilitySettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AccessibilitySettingsFragment.kt index 2b12c1f85b1d..3be3ed3f5905 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AccessibilitySettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AccessibilitySettingsFragment.kt @@ -1,31 +1,31 @@ -/* - * Copyright (c) 2022 Brayan Oliveira - * - * 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.preferences - -import com.ichi2.anki.R - -/** - * Fragment with preferences related to notifications - */ -class AccessibilitySettingsFragment : SettingsFragment() { - override val preferenceResource: Int - get() = R.xml.preferences_accessibility - override val analyticsScreenNameConstant: String - get() = "prefs.accessibility" - - override fun initSubscreen() { - } -} +/* + * Copyright (c) 2022 Brayan Oliveira + * + * 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.preferences + +import com.ichi2.anki.R + +/** + * Fragment with preferences related to notifications + */ +class AccessibilitySettingsFragment : SettingsFragment() { + override val preferenceResource: Int + get() = R.xml.preferences_accessibility + override val analyticsScreenNameConstant: String + get() = "prefs.accessibility" + + override fun initSubscreen() { + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ConstantUniquenessTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ConstantUniquenessTest.kt index b37cded87dab..357ae23cfa03 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ConstantUniquenessTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ConstantUniquenessTest.kt @@ -1,55 +1,55 @@ -/* - * Copyright (c) 2024 WPum <27683756+WPum@users.noreply.github.com> - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A - * PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ - -package com.ichi2.anki - -import com.ichi2.anki.notifications.NotificationId -import com.ichi2.anki.worker.UniqueWorkNames -import org.junit.Test -import kotlin.reflect.KClass -import kotlin.reflect.KVisibility -import kotlin.reflect.full.declaredMemberProperties -import kotlin.test.assertFalse -import kotlin.test.assertNotNull - -class ConstantUniquenessTest { - - @Test - fun testConstantUniqueness() { - assertConstantUniqueness(NotificationId::class) - assertConstantUniqueness(UniqueWorkNames::class) - } - - companion object { - - /** - * To check whether all PUBLIC CONST values in an object are unique. - */ - fun assertConstantUniqueness(clazz: KClass) { - assertNotNull(clazz.objectInstance, "Can only check objects for uniqueness") - val valueSet = HashSet() - for (prop in clazz.declaredMemberProperties) { - if (!prop.isConst || prop.visibility != KVisibility.PUBLIC) { - continue - } - // use .call() since clazz represents an object - val value = prop.call() - assertFalse(valueSet.contains(value), "Duplicate value ('$value') for constant in ${clazz.qualifiedName}") - valueSet.add(value) - } - } - } -} +/* + * Copyright (c) 2024 WPum <27683756+WPum@users.noreply.github.com> + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki + +import com.ichi2.anki.notifications.NotificationId +import com.ichi2.anki.worker.UniqueWorkNames +import org.junit.Test +import kotlin.reflect.KClass +import kotlin.reflect.KVisibility +import kotlin.reflect.full.declaredMemberProperties +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +class ConstantUniquenessTest { + + @Test + fun testConstantUniqueness() { + assertConstantUniqueness(NotificationId::class) + assertConstantUniqueness(UniqueWorkNames::class) + } + + companion object { + + /** + * To check whether all PUBLIC CONST values in an object are unique. + */ + fun assertConstantUniqueness(clazz: KClass) { + assertNotNull(clazz.objectInstance, "Can only check objects for uniqueness") + val valueSet = HashSet() + for (prop in clazz.declaredMemberProperties) { + if (!prop.isConst || prop.visibility != KVisibility.PUBLIC) { + continue + } + // use .call() since clazz represents an object + val value = prop.call() + assertFalse(valueSet.contains(value), "Duplicate value ('$value') for constant in ${clazz.qualifiedName}") + valueSet.add(value) + } + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt index 6275c77bbd4b..f13ed608c2ff 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt @@ -1,30 +1,30 @@ -/* - * Copyright (c) 2024 Brayan Oliveira - * - * 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.previewer - -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.junit.jupiter.api.Test - -class PreviewerViewModelTest { - @Test - fun `type answer fields are removed in questions`() { - assertThat( - PreviewerViewModel.typeAnsQuestionFilter("creu [[type:leu]]"), - equalTo("creu ") - ) - } -} +/* + * Copyright (c) 2024 Brayan Oliveira + * + * 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.previewer + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.Test + +class PreviewerViewModelTest { + @Test + fun `type answer fields are removed in questions`() { + assertThat( + PreviewerViewModel.typeAnsQuestionFilter("creu [[type:leu]]"), + equalTo("creu ") + ) + } +} diff --git a/jitpack.yml b/jitpack.yml index 384a44ef2592..83314e1e15da 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -1,15 +1,15 @@ -# We want control over the SDK used to build since we need newer JDKs -# Use the latest LTS supported by temurin with version format xx.yy.zz-tem (ignore sub-version) -# You may find versions e.g. for JDK21 like so https://adoptium.net/temurin/archive/?version=21 -# You may verify that a JDK is installed by looking at the "sdk list java" output from, for example: -# https://jitpack.io/com/github/ankidroid/Anki-Android/v2.18alpha7/build.log (just use a recent tag, -# if it has not built yet you will have to wait for it to build then on refresh the build log should exist) -before_install: - - sdk update - - sdk list java - - sdk install java 21.0.2-tem - - sdk use java 21.0.2-tem - -# We can do the absolute minimum to build the API module, no need to build AnkiDroid module -install: - - ./gradlew :api:publishToMavenLocal +# We want control over the SDK used to build since we need newer JDKs +# Use the latest LTS supported by temurin with version format xx.yy.zz-tem (ignore sub-version) +# You may find versions e.g. for JDK21 like so https://adoptium.net/temurin/archive/?version=21 +# You may verify that a JDK is installed by looking at the "sdk list java" output from, for example: +# https://jitpack.io/com/github/ankidroid/Anki-Android/v2.18alpha7/build.log (just use a recent tag, +# if it has not built yet you will have to wait for it to build then on refresh the build log should exist) +before_install: + - sdk update + - sdk list java + - sdk install java 21.0.2-tem + - sdk use java 21.0.2-tem + +# We can do the absolute minimum to build the API module, no need to build AnkiDroid module +install: + - ./gradlew :api:publishToMavenLocal From d76e69586db51959dea7a493a0b643cde4e8dca3 Mon Sep 17 00:00:00 2001 From: SanjaySargam Date: Thu, 25 Jul 2024 20:11:05 +0530 Subject: [PATCH 06/10] refactor: rename pager Renaming these two pager ids so that android-studio should know both are different pager --- AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt | 2 +- .../src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt | 2 +- AnkiDroid/src/main/res/layout/card_template_editor_activity.xml | 2 +- AnkiDroid/src/main/res/layout/dialog_set_due_date.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index f4094e488dd8..3202bcbf1b80 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -144,7 +144,7 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { } slidingTabLayout = findViewById(R.id.sliding_tabs) - viewPager = findViewById(R.id.pager) + viewPager = findViewById(R.id.card_template_editor_pager) setNavigationBarColor(R.attr.appBarColor) // Disable the home icon diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt index f865709f55b9..1c50d53a5733 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt @@ -125,7 +125,7 @@ class SetDueDateDialog : DialogFragment() { viewModel.isValidFlow.collect { isValid -> positiveButton.isEnabled = isValid } } // setup viewpager + tabs - val viewPager = findViewById(R.id.pager)!! + val viewPager = findViewById(R.id.set_due_date_pager)!! viewPager.adapter = DueDateStateAdapter(this@SetDueDateDialog) val tabLayout = findViewById(R.id.tab_layout)!! TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int -> diff --git a/AnkiDroid/src/main/res/layout/card_template_editor_activity.xml b/AnkiDroid/src/main/res/layout/card_template_editor_activity.xml index 766e7d8a5da0..88e225a2332f 100644 --- a/AnkiDroid/src/main/res/layout/card_template_editor_activity.xml +++ b/AnkiDroid/src/main/res/layout/card_template_editor_activity.xml @@ -27,7 +27,7 @@ app:tabGravity="start" app:tabMode="scrollable" /> diff --git a/AnkiDroid/src/main/res/layout/dialog_set_due_date.xml b/AnkiDroid/src/main/res/layout/dialog_set_due_date.xml index e108e3df5194..e4e357b5945c 100644 --- a/AnkiDroid/src/main/res/layout/dialog_set_due_date.xml +++ b/AnkiDroid/src/main/res/layout/dialog_set_due_date.xml @@ -53,7 +53,7 @@ From f7d1b97b254df5e0edb306a21ef680bd02535dd4 Mon Sep 17 00:00:00 2001 From: anoop Date: Tue, 4 Jun 2024 19:37:22 +0530 Subject: [PATCH 07/10] Introducing AnalyticsWidgetProvider as ancestor of all widget classes. This will ensure any widgets Timber and send analytics on enabling, disabling and updating. --- .../java/com/ichi2/widget/AddNoteWidget.kt | 27 ++--- .../ichi2/widget/AnalyticsWidgetProvider.kt | 99 +++++++++++++++++++ .../com/ichi2/widget/AnkiDroidWidgetSmall.kt | 23 ++--- .../widget/AnalyticalWidgetProviderTest.kt | 67 +++++++++++++ 4 files changed, 182 insertions(+), 34 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/widget/AnalyticalWidgetProviderTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/AddNoteWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/AddNoteWidget.kt index cb6d4d29086f..2da0f89aa42c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/AddNoteWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/AddNoteWidget.kt @@ -15,34 +15,21 @@ package com.ichi2.widget import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider import android.content.Context import android.widget.RemoteViews import androidx.core.app.PendingIntentCompat -import com.ichi2.anki.IntentHandler import com.ichi2.anki.R import com.ichi2.anki.analytics.UsageAnalytics import com.ichi2.anki.noteeditor.NoteEditorLauncher -import timber.log.Timber -class AddNoteWidget : AppWidgetProvider() { - override fun onEnabled(context: Context) { - super.onEnabled(context) - UsageAnalytics.sendAnalyticsEvent(this.javaClass.simpleName, "enabled") - } - - override fun onDisabled(context: Context) { - super.onDisabled(context) - UsageAnalytics.sendAnalyticsEvent(this.javaClass.simpleName, "disabled") - } +class AddNoteWidget : AnalyticsWidgetProvider() { - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - if (!IntentHandler.grantedStoragePermissions(context, showToast = false)) { - Timber.w("Opening AddNote widget without storage access") - return - } - Timber.d("onUpdate") + override fun performUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + usageAnalytics: UsageAnalytics + ) { updateWidgets(context, appWidgetManager, appWidgetIds) } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt b/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt new file mode 100644 index 000000000000..926d0d775b02 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2024 Anoop + * + * 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.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import androidx.annotation.CallSuper +import com.ichi2.anki.IntentHandler +import com.ichi2.anki.analytics.UsageAnalytics +import timber.log.Timber + +/** + * AnalyticsWidgetProvider is an abstract base class for App Widgets that integrates + * with UsageAnalytics to send analytics events when the widget is enabled, disabled, + * or updated. + * + * This class should always be used as the base class for App Widgets in this application. + * Direct usage of AppWidgetProvider should be avoided. + * TODO: Add a lint rule to forbid the direct use of AppWidgetProvider. + * + * Subclasses must override [performUpdate] to define the widget update logic. + * + * - To use this class, extend it and implement the [performUpdate] method. + * - Override [onUpdate] if additional logic is required beyond [performUpdate]. + */ +abstract class AnalyticsWidgetProvider : AppWidgetProvider() { + + /** + * Called when the widget is enabled. Sends an analytics event. + * + * @param context The context in which the receiver is running. + */ + @CallSuper + override fun onEnabled(context: Context) { + super.onEnabled(context) + Timber.d("${this.javaClass.name}: Widget enabled") + UsageAnalytics.sendAnalyticsEvent(this.javaClass.simpleName, "enabled") + } + + /** + * Called when the widget is disabled. Sends an analytics event. + * + * @param context The context in which the receiver is running. + */ + @CallSuper + override fun onDisabled(context: Context) { + super.onDisabled(context) + Timber.d("${this.javaClass.name}: Widget disabled") + UsageAnalytics.sendAnalyticsEvent(this.javaClass.simpleName, "disabled") + } + + /** + * Called to update the widget. Checks storage permissions and delegates to [performUpdate]. + * + * @param context The context in which the receiver is running. + * @param appWidgetManager The AppWidgetManager instance to use for updating widgets. + * @param appWidgetIds The app widget IDs to update. + */ + final override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (!IntentHandler.grantedStoragePermissions(context, showToast = false)) { + Timber.w("Opening widget ${this.javaClass.name} without storage access") + return + } + // Pass usageAnalytics to performUpdate + Timber.d("${this.javaClass.name}: performUpdate") + performUpdate(context, appWidgetManager, appWidgetIds, UsageAnalytics) + } + + /** + * Override this method to implement Widget functionality + * + * Called when the [AnalyticsWidgetProvider] is asked to provide [RemoteViews] for a set of Widgets AND the Anki collection is accessible. + * + * @see AppWidgetProvider.onUpdate + * + * @param context The context in which the receiver is running. + * @param appWidgetManager The AppWidgetManager instance to use for updating widgets. + * @param appWidgetIds The app widget IDs to update. + * @param usageAnalytics The UsageAnalytics instance for logging analytics events. + */ + + abstract fun performUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, usageAnalytics: UsageAnalytics) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt b/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt index 2d61effc3269..ae6a34f362b6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/AnkiDroidWidgetSmall.kt @@ -17,7 +17,6 @@ package com.ichi2.widget import android.app.PendingIntent import android.app.Service import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context @@ -33,7 +32,6 @@ import androidx.core.content.ContextCompat import androidx.core.content.edit import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.IntentHandler -import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions import com.ichi2.anki.R import com.ichi2.anki.analytics.UsageAnalytics import com.ichi2.anki.preferences.sharedPrefs @@ -42,30 +40,27 @@ import com.ichi2.utils.KotlinCleanup import timber.log.Timber import kotlin.math.sqrt -class AnkiDroidWidgetSmall : AppWidgetProvider() { - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - Timber.d("SmallWidget: onUpdate") - if (!grantedStoragePermissions(context, showToast = false)) { - Timber.w("Opening AnkiDroid Small widget without storage access") - return - } +/** + * AnkiDroidWidgetSmall is a small-sized home screen widget for the AnkiDroid application. + * This widget displays the number of due cards and an estimated review time. + * It updates periodically and can respond to certain actions like resizing. + */ + +class AnkiDroidWidgetSmall : AnalyticsWidgetProvider() { + + override fun performUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, usageAnalytics: UsageAnalytics) { WidgetStatus.updateInBackground(context) } - override fun onEnabled(context: Context) { super.onEnabled(context) - Timber.d("SmallWidget: Widget enabled") val preferences = context.sharedPrefs() preferences.edit(commit = true) { putBoolean("widgetSmallEnabled", true) } - UsageAnalytics.sendAnalyticsEvent(this.javaClass.simpleName, "enabled") } override fun onDisabled(context: Context) { super.onDisabled(context) - Timber.d("SmallWidget: Widget disabled") val preferences = context.sharedPrefs() preferences.edit(commit = true) { putBoolean("widgetSmallEnabled", false) } - UsageAnalytics.sendAnalyticsEvent(this.javaClass.simpleName, "disabled") } override fun onReceive(context: Context, intent: Intent) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnalyticalWidgetProviderTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnalyticalWidgetProviderTest.kt new file mode 100644 index 000000000000..69bd0da9c6cc --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/widget/AnalyticalWidgetProviderTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 David Allison + * Copyright (c) 2024 Anoop + * + * 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 . + */ + +import android.appwidget.AppWidgetManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.analytics.UsageAnalytics +import com.ichi2.widget.AnalyticsWidgetProvider +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AnalyticalWidgetProviderTest : RobolectricTest() { + + @Before + override fun setUp() { + super.setUp() + mockkObject(UsageAnalytics) + every { UsageAnalytics.sendAnalyticsEvent(any(), any()) } answers { } + } + + @After + override fun tearDown() { + super.tearDown() + unmockkObject(UsageAnalytics) + } + + @Test + fun testAnalyticsEventLogging() { + val widgetProvider = TestWidgetProvider() + + widgetProvider.onEnabled(targetContext) + + verify { UsageAnalytics.sendAnalyticsEvent("TestWidgetProvider", "enabled") } + } + + private class TestWidgetProvider : AnalyticsWidgetProvider() { + override fun performUpdate( + context: android.content.Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + usageAnalytics: UsageAnalytics + ) { + // Do nothing + } + } +} From f52b6276dae65228bf4bba75fc717985799bb5f0 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 25 Jul 2024 12:59:46 +0530 Subject: [PATCH 08/10] fix: cloze number incorrect on undo * refactor: update the viewmodel tests --- .../InstantEditorViewModel.kt | 14 +++-- .../InstantEditorViewModelTest.kt | 52 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt index fbf8253dda77..7bcb84e2a134 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt @@ -62,6 +62,10 @@ class InstantEditorViewModel : ViewModel(), OnErrorListener { // List to store the cloze integers private val intClozeList = mutableListOf() + /** The number of words which are marked as cloze deletions */ + @VisibleForTesting + val clozeDeletionCount get() = intClozeList.size + private val _currentClozeMode = MutableStateFlow(InstantNoteEditorActivity.ClozeMode.INCREMENT) val currentClozeMode: StateFlow = _currentClozeMode.asStateFlow() @@ -178,14 +182,14 @@ class InstantEditorViewModel : ViewModel(), OnErrorListener { } private fun shouldResetClozeNumber(number: Int) { - val index = intClozeList.indexOf(number) - if (index != -1) { - intClozeList.removeAt(index) - } + intClozeList.remove(number) // Reset cloze number if the list is empty if (intClozeList.isEmpty()) { _currentClozeNumber.value = 1 + } else { + // not null for sure + _currentClozeNumber.value = intClozeList.maxOrNull()!! + 1 } } @@ -248,7 +252,7 @@ class InstantEditorViewModel : ViewModel(), OnErrorListener { if (currentClozeMode.value == InstantNoteEditorActivity.ClozeMode.INCREMENT) { incrementClozeNumber() } - intClozeList.add(currentClozeNumber) + intClozeList.add(clozeNumber) val punctuation: String? = matcher?.groups?.get(2)?.value if (!punctuation.isNullOrEmpty()) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt index e456f611bad4..d90464d7276c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt @@ -55,6 +55,37 @@ class InstantEditorViewModelTest : RobolectricTest() { } } + @Test + fun `test cloze number reset to 1`() = runViewModelTest { + val sentenceArray = mutableListOf("Hello", "world") + + toggleAllClozeDeletions(sentenceArray) + + assertEquals("all clozes are detected", clozeDeletionCount, 2) + + toggleAllClozeDeletions(sentenceArray) + + assertEquals("all cloze deletions are removed", clozeDeletionCount, 0) + assertEquals("cloze number is reset if there are no clozes", currentClozeNumber, 1) + } + + @Test + fun `test cloze number is reset to max value from cloze list`() = runViewModelTest { + val sentenceArray = mutableListOf("Hello", "world", "this", "is", "test", "sentence") + + // cloze on the first 3 words + toggleClozeDeletions(sentenceArray, 0, 1, 2) + + // disable cloze on the first 2, leaving "this" as {{c3:: + toggleClozeDeletions(sentenceArray, 0, 1) + + assertEquals("cloze number is 'Current Cloze Number + 1'", currentClozeNumber, 4) + + // remove the remaining cloze all clozes + toggleClozeDeletion(sentenceArray, 2) + assertEquals("cloze number is reset if all clozes are removed", currentClozeNumber, 1) + } + @Test fun testSavingNoteWithNoCloze() = runViewModelTest { editorNote.setField(0, "Hello") @@ -201,3 +232,24 @@ class InstantEditorViewModelTest : RobolectricTest() { } } } + +context (InstantEditorViewModel) +private fun toggleAllClozeDeletions(words: MutableList) { + for (index in words.indices) { + words[index] = buildClozeText(words[index]) + } +} + +context (InstantEditorViewModel) +@Suppress("SameParameterValue") +private fun toggleClozeDeletions(words: MutableList, vararg indices: Int) { + for (index in indices) { + words[index] = buildClozeText(words[index]) + } +} + +context (InstantEditorViewModel) +@Suppress("SameParameterValue") +private fun toggleClozeDeletion(words: MutableList, index: Int) { + words[index] = buildClozeText(words[index]) +} From 87e2a79b676c6309496317141340d6db52e82fa5 Mon Sep 17 00:00:00 2001 From: SanjaySargam Date: Thu, 11 Jul 2024 23:01:40 +0530 Subject: [PATCH 09/10] card-template-editor: keyboard shortcuts - Implemented keyboard shortcuts: - Ctrl+P: Perform Preview - Ctrl+1: Navigate to Front Edit - Ctrl+2: Navigate to Back Edit - Ctrl+3: Navigate to Styling Edit - Ctrl+S: Save Note - Ctrl+R: Rename template - Ctrl+I: Show Insert Field Dialog - Ctrl+A: Add template - Ctrl+B: Open Browser Appearance - Ctrl+D: Delete template - Ctrl+O: Display Deck Override Dialog - Ctrl+M: Copy Markdown Template to Clipboard --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 242 ++++++++++++------ 1 file changed, 163 insertions(+), 79 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 3202bcbf1b80..e7ef36880e10 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -262,13 +262,61 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { } override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { - if (keyCode == KeyEvent.KEYCODE_P) { - if (event.isCtrlPressed) { - val currentFragment = currentFragment - currentFragment?.performPreview() + val currentFragment = currentFragment ?: return super.onKeyUp(keyCode, event) + if (event.isCtrlPressed) { + when (keyCode) { + KeyEvent.KEYCODE_P -> { + Timber.i("Ctrl+P: Perform preview from keypress") + currentFragment.performPreview() + } + KeyEvent.KEYCODE_1 -> { + Timber.i("Ctrl+1: Edit front template from keypress") + currentFragment.bottomNavigation.selectedItemId = R.id.front_edit + } + KeyEvent.KEYCODE_2 -> { + Timber.i("Ctrl+2: Edit back template from keypress") + currentFragment.bottomNavigation.selectedItemId = R.id.back_edit + } + KeyEvent.KEYCODE_3 -> { + Timber.i("Ctrl+3: Edit styling from keypress") + currentFragment.bottomNavigation.selectedItemId = R.id.styling_edit + } + KeyEvent.KEYCODE_S -> { + Timber.i("Ctrl+S: Save note from keypress") + currentFragment.saveNoteType() + } + KeyEvent.KEYCODE_I -> { + Timber.i("Ctrl+I: Insert field from keypress") + currentFragment.showInsertFieldDialog() + } + KeyEvent.KEYCODE_A -> { + Timber.i("Ctrl+A: Add card template from keypress") + currentFragment.addCardTemplate() + } + KeyEvent.KEYCODE_R -> { + Timber.i("Ctrl+R: Rename card from keypress") + currentFragment.showRenameDialog() + } + KeyEvent.KEYCODE_B -> { + Timber.i("Ctrl+B: Open browser appearance from keypress") + currentFragment.openBrowserAppearance() + } + KeyEvent.KEYCODE_D -> { + Timber.i("Ctrl+D: Delete card from keypress") + currentFragment.deleteCardTemplate() + } + KeyEvent.KEYCODE_O -> { + Timber.i("Ctrl+O: Display deck override dialog from keypress") + currentFragment.displayDeckOverrideDialog(currentFragment.tempModel) + } + KeyEvent.KEYCODE_M -> { + Timber.i("Ctrl+M: Copy markdown from keypress") + currentFragment.copyMarkdownTemplateToClipboard() + } + else -> return super.onKeyUp(keyCode, event) } } - return super.onKeyUp(keyCode, event) + return true } @get:VisibleForTesting @@ -322,16 +370,18 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { private lateinit var templateEditor: CardTemplateEditor private var tabLayoutMediator: TabLayoutMediator? = null + lateinit var tempModel: CardTemplateNotetype + lateinit var bottomNavigation: BottomNavigationView override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { // Storing a reference to the templateEditor allows us to use member variables templateEditor = activity as CardTemplateEditor val mainView = inflater.inflate(R.layout.card_template_editor_item, container, false) val cardIndex = requireArguments().getInt(CARD_INDEX) - val tempModel = templateEditor.tempModel + tempModel = templateEditor.tempModel!! // Load template val template: JSONObject = try { - tempModel!!.getTemplate(cardIndex) + tempModel.getTemplate(cardIndex) } catch (e: JSONException) { Timber.d(e, "Exception loading template in CardTemplateFragment. Probably stale fragment.") return mainView @@ -343,7 +393,7 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { editorEditText.customInsertionActionModeCallback = ActionModeCallback() - val bottomNavigation: BottomNavigationView = mainView.findViewById(R.id.card_template_editor_bottom_navigation) + bottomNavigation = mainView.findViewById(R.id.card_template_editor_bottom_navigation) bottomNavigation.setOnItemSelectedListener { item: MenuItem -> val currentSelectedId = item.itemId templateEditor.tabToViewId[cardIndex] = currentSelectedId @@ -440,7 +490,7 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { @NeedsTest( "the kotlin migration made this method crash due to a recursive call when the dialog would return its data" ) - private fun showInsertFieldDialog() { + fun showInsertFieldDialog() { templateEditor.fieldNames?.let { fieldNames -> templateEditor.showDialogFragment(InsertFieldDialog.newInstance(fieldNames)) } @@ -450,7 +500,7 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { @NeedsTest("Prefill is correct") @NeedsTest("Does not work for Cloze/Occlusion") @NeedsTest("UI is updated on success") - private fun showRenameDialog() { + fun showRenameDialog() { if (noteTypeCreatesDynamicNumberOfNotes()) { Timber.w("attempted to rename a dynamic note type") return @@ -564,92 +614,58 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - val col = templateEditor.getColUnsafe - val tempModel = templateEditor.tempModel - when (menuItem.itemId) { + return when (menuItem.itemId) { R.id.action_add -> { Timber.i("CardTemplateEditor:: Add template button pressed") - // Show confirmation dialog - val ordinal = templateEditor.viewPager.currentItem - // isOrdinalPendingAdd method will check if there are any new card types added or not, - // if TempModel has new card type then numAffectedCards will be 0 by default. - val numAffectedCards = if (!CardTemplateNotetype.isOrdinalPendingAdd(tempModel!!, ordinal)) { - col.notetypes.tmplUseCount(tempModel.notetype, ordinal) - } else { - 0 - } - confirmAddCards(tempModel.notetype, numAffectedCards) + addCardTemplate() + return true + } + R.id.action_reposition -> { + showRepositionDialog() + return true + } + R.id.action_rename -> { + Timber.i("CardTemplateEditor:: Rename button pressed") + showRenameDialog() + return true + } + R.id.action_copy_as_markdown -> { + Timber.i("CardTemplateEditor:: Copy markdown button pressed") + copyMarkdownTemplateToClipboard() + return true + } + R.id.action_insert_field -> { + Timber.i("CardTemplateEditor:: Insert field button pressed") + showInsertFieldDialog() return true } - R.id.action_reposition -> showRepositionDialog() - R.id.action_rename -> showRenameDialog() - R.id.action_copy_as_markdown -> copyMarkdownTemplateToClipboard() - R.id.action_insert_field -> showInsertFieldDialog() R.id.action_delete -> { - Timber.i("CardTemplateEditor:: Delete template button pressed") - val res = resources - val ordinal = templateEditor.viewPager.currentItem - val template = tempModel!!.getTemplate(ordinal) - // Don't do anything if only one template - if (tempModel.templateCount < 2) { - templateEditor.showSimpleMessageDialog(res.getString(R.string.card_template_editor_cant_delete)) - return true - } - - if (deletionWouldOrphanNote(col, tempModel, ordinal)) { - return true - } - - // Show confirmation dialog - val numAffectedCards = if (!CardTemplateNotetype.isOrdinalPendingAdd(tempModel, ordinal)) { - Timber.d("Ordinal is not a pending add, so we'll get the current card count for confirmation") - col.notetypes.tmplUseCount(tempModel.notetype, ordinal) - } else { - 0 - } - confirmDeleteCards(template, tempModel.notetype, numAffectedCards) + Timber.i("CardTemplateEditor:: Delete button pressed") + deleteCardTemplate() return true } R.id.action_add_deck_override -> { - displayDeckOverrideDialog(tempModel!!) + Timber.i("CardTemplateEditor:: Deck override button pressed") + displayDeckOverrideDialog(tempModel) return true } R.id.action_preview -> { + Timber.i("CardTemplateEditor:: Preview button pressed") performPreview() return true } R.id.action_confirm -> { - Timber.i("CardTemplateEditor:: Save model button pressed") - if (modelHasChanged()) { - val confirmButton = templateEditor.findViewById(R.id.action_confirm) - if (confirmButton != null) { - if (!confirmButton.isEnabled) { - Timber.d("CardTemplateEditor::discarding extra click after button disabled") - return true - } - confirmButton.isEnabled = false - } - launchCatchingTask(resources.getString(R.string.card_template_editor_save_error)) { - requireActivity().withProgress(resources.getString(R.string.saving_model)) { - withCol { tempModel!!.saveToDatabase() } - } - onModelSaved() - } - } else { - Timber.d("CardTemplateEditor:: model has not changed, exiting") - templateEditor.finish() - } - - return true + Timber.i("CardTemplateEditor:: Save button pressed") + saveNoteType() } R.id.action_card_browser_appearance -> { - Timber.i("CardTemplateEditor::Card Browser Template button pressed") - val currentTemplate = getCurrentTemplate() - currentTemplate?.let { launchCardBrowserAppearance(it) } - return true + Timber.i("CardTemplateEditor:: Card browser appearance button pressed") + openBrowserAppearance() + } + else -> { + false } } - return false } }, viewLifecycleOwner, @@ -657,6 +673,74 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { ) } + // TODO: Use withCol {} instead + fun deleteCardTemplate() { + val col = templateEditor.getColUnsafe + val tempModel = templateEditor.tempModel + val ordinal = templateEditor.viewPager.currentItem + val template = tempModel!!.getTemplate(ordinal) + // Don't do anything if only one template + if (tempModel.templateCount < 2) { + templateEditor.showSimpleMessageDialog(resources.getString(R.string.card_template_editor_cant_delete)) + return + } + + if (deletionWouldOrphanNote(col, tempModel, ordinal)) { + return + } + + // Show confirmation dialog + val numAffectedCards = if (!CardTemplateNotetype.isOrdinalPendingAdd(tempModel, ordinal)) { + Timber.d("Ordinal is not a pending add, so we'll get the current card count for confirmation") + col.notetypes.tmplUseCount(tempModel.notetype, ordinal) + } else { + 0 + } + confirmDeleteCards(template, tempModel.notetype, numAffectedCards) + } + + fun openBrowserAppearance(): Boolean { + val currentTemplate = getCurrentTemplate() + currentTemplate?.let { launchCardBrowserAppearance(it) } + return true + } + + fun addCardTemplate() { + // Show confirmation dialog + val ordinal = templateEditor.viewPager.currentItem + // isOrdinalPendingAdd method will check if there are any new card types added or not, + // if TempModel has new card type then numAffectedCards will be 0 by default. + val numAffectedCards = if (!CardTemplateNotetype.isOrdinalPendingAdd(templateEditor.tempModel!!, ordinal)) { + templateEditor.getColUnsafe.notetypes.tmplUseCount(templateEditor.tempModel!!.notetype, ordinal) + } else { + 0 + } + confirmAddCards(templateEditor.tempModel!!.notetype, numAffectedCards) + } + + fun saveNoteType(): Boolean { + if (modelHasChanged()) { + val confirmButton = templateEditor.findViewById(R.id.action_confirm) + if (confirmButton != null) { + if (!confirmButton.isEnabled) { + Timber.d("CardTemplateEditor::discarding extra click after button disabled") + return true + } + confirmButton.isEnabled = false + } + launchCatchingTask(resources.getString(R.string.card_template_editor_save_error)) { + requireActivity().withProgress(resources.getString(R.string.saving_model)) { + withCol { templateEditor.tempModel!!.saveToDatabase() } + } + onModelSaved() + } + } else { + Timber.d("CardTemplateEditor:: model has not changed, exiting") + templateEditor.finish() + } + return true + } + private val currentTemplate: CardTemplate? get() = try { val tempModel = templateEditor.tempModel @@ -672,7 +756,7 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { } /** Copies the template to clipboard in markdown format */ - private fun copyMarkdownTemplateToClipboard() { + fun copyMarkdownTemplateToClipboard() { // A number of users who post their templates to Reddit/Discord have these badly formatted // It makes it much easier for people to understand if these are provided as markdown val template = currentTemplate ?: return @@ -718,7 +802,7 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { } } - private fun displayDeckOverrideDialog(tempModel: CardTemplateNotetype) = launchCatchingTask { + fun displayDeckOverrideDialog(tempModel: CardTemplateNotetype) = launchCatchingTask { val activity = requireActivity() as AnkiActivity if (tempModel.notetype.isCloze) { showSnackbar(getString(R.string.multimedia_editor_something_wrong), Snackbar.LENGTH_SHORT) From 76eea74a2b284659b45bdc4ef85e6e5130a16860 Mon Sep 17 00:00:00 2001 From: SanjaySargam Date: Wed, 10 Jul 2024 00:33:10 +0530 Subject: [PATCH 10/10] Implement Right-Click Support for Long Click Listeners -Added right-click support for all existing long click listeners. -Created an OnContextAndLongClickListener interface to handle both context click and long click events consistently. -This interface includes an onAction method to define the common action (context and long click). -The interface ensures that both listeners are set without duplicating code. -Applied the OnContextAndLongClickListener interface to handle right-click and long-click events uniformly. --- .../main/java/com/ichi2/anki/DeckPicker.kt | 12 ++-- .../main/java/com/ichi2/anki/NoteEditor.kt | 3 +- .../anki/OnContextAndLongClickListener.kt | 57 +++++++++++++++++++ .../ichi2/anki/dialogs/DeckSelectionDialog.kt | 25 +++++--- .../anki/dialogs/tags/TagsArrayAdapter.kt | 10 ++-- .../com/ichi2/anki/dialogs/tags/TagsDialog.kt | 12 ++-- .../com/ichi2/anki/widgets/DeckAdapter.kt | 11 ++-- 7 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/OnContextAndLongClickListener.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index e097bc0a2f97..2a9158b7f220 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -43,7 +43,6 @@ import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View -import android.view.View.OnLongClickListener import android.view.ViewPropertyAnimator import android.widget.Filterable import android.widget.ImageButton @@ -442,9 +441,13 @@ open class DeckPicker : } } - private val deckLongClickListener = OnLongClickListener { v -> + private val deckContextAndLongClickListener = OnContextAndLongClickListener { v -> val deckId = v.tag as DeckId - Timber.i("DeckPicker:: Long tapped on deck with id %d", deckId) + showDeckPickerContextMenu(deckId) + true + } + + private fun showDeckPickerContextMenu(deckId: DeckId) { launchCatchingTask { val (deckName, isDynamic, hasBuriedInDeck) = withCol { decks.select(deckId) @@ -464,7 +467,6 @@ open class DeckPicker : ) ) } - true } private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { @@ -558,7 +560,7 @@ open class DeckPicker : setDeckClickListener(deckClickListener) setCountsClickListener(countsClickListener) setDeckExpanderClickListener(deckExpanderClickListener) - setDeckLongClickListener(deckLongClickListener) + setDeckContextAndLongClickListener(deckContextAndLongClickListener) enablePartialTransparencyForBackground(hasDeckPickerBackground) } recyclerView.adapter = deckListAdapter diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt index f9bfad2230cd..88133e7b4a5b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt @@ -78,6 +78,7 @@ import com.google.android.material.snackbar.Snackbar import com.ichi2.anim.ActivityTransitionAnimation import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.OnContextAndLongClickListener.Companion.setOnContextAndLongClickListener import com.ichi2.anki.bottomsheet.ImageOcclusionBottomSheetFragment import com.ichi2.anki.dialogs.ConfirmationDialog import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener @@ -2232,7 +2233,7 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su // Allow Ctrl + 1...Ctrl + 0 for item 10. v.tag = (visualIndex % 10).toString() - v.setOnLongClickListener { + v.setOnContextAndLongClickListener { displayEditToolbarDialog(b) true } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/OnContextAndLongClickListener.kt b/AnkiDroid/src/main/java/com/ichi2/anki/OnContextAndLongClickListener.kt new file mode 100644 index 000000000000..a07a5f19cf5e --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/OnContextAndLongClickListener.kt @@ -0,0 +1,57 @@ +/*************************************************************************************** + * * + * Copyright (c) 2024 Sanjay Sargam * + * * + * This program is free software; you can redistribute it and/or modify it under * + * the terms of the GNU General Public License as published by the Free Software * + * Foundation; either version 3 of the License, or (at your option) any later * + * version. * + * * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY * + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * + * PARTICULAR PURPOSE. See the GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License along with * + * this program. If not, see . * + ****************************************************************************************/ + +package com.ichi2.anki + +import android.view.View +import timber.log.Timber + +/** + * A listener that has the same action for both "context click" (i.e., mostly right-click) and "long click" (i.e., holding the finger on the view). + * + * * Note: In some contexts, a long press (long click) is expected to be informational, whereas a right-click (context click) is expected to be functional. + * * Ensure that using the same action for both is appropriate for your use case. + */ +fun interface OnContextAndLongClickListener : View.OnContextClickListener, View.OnLongClickListener { + /** + * The action to do for both contextClick and long click + * @returns whether the operation was successful + */ + fun onAction(v: View): Boolean + + override fun onContextClick(v: View): Boolean { + Timber.i("${this.javaClass}: user context clicked") + return onAction(v) + } + + override fun onLongClick(v: View): Boolean { + Timber.i("${this.javaClass}: user long clicked") + return onAction(v) + } + + companion object { + /** + * Ensures [this] gets both a long click and a context click listener. + * @see View.setOnLongClickListener + * @see View.setOnContextClickListener + */ + fun View.setOnContextAndLongClickListener(listener: OnContextAndLongClickListener?) { + setOnLongClickListener(listener) + setOnContextClickListener(listener) + } + } +} 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 fdb3e2c66430..ae011845cdb9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt @@ -39,6 +39,7 @@ import androidx.recyclerview.widget.RecyclerView import anki.decks.DeckTreeNode import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.DeckSpinnerSelection +import com.ichi2.anki.OnContextAndLongClickListener.Companion.setOnContextAndLongClickListener import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment import com.ichi2.anki.dialogs.DeckSelectionDialog.DecksArrayAdapter.DecksFilter @@ -179,7 +180,20 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() { } } - private fun showSubDeckDialog(parentDeckPath: String) { + /** + * Displays a dialog to create a subdeck under the specified parent deck. + * + * If the `deckID` is equal to `DeckSpinnerSelection.ALL_DECKS_ID`, a toast message is shown + * indicating that a subdeck cannot be created for "All Decks," and the dialog is not displayed. + * + * @param parentDeckPath The path of the parent deck under which the subdeck will be created. + * @param deckID The ID of the deck where the subdeck should be created. + */ + private fun showSubDeckDialog(parentDeckPath: String, deckID: DeckId) { + if (deckID == DeckSpinnerSelection.ALL_DECKS_ID) { + context?.let { showThemedToast(it, R.string.cannot_create_subdeck_for_all_decks, true) } + return + } launchCatchingTask { val parentId = withCol { decks.id(parentDeckPath) } val createDeckDialog = CreateDeckDialog(requireActivity(), R.string.create_subdeck, CreateDeckDialog.DeckDialogType.SUB_DECK, parentId) @@ -265,12 +279,9 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() { expander.setOnClickListener { toggleExpansion(deckID) } - deckHolder.setOnLongClickListener { // creating sub deck with parent deck path - if (deckID == DeckSpinnerSelection.ALL_DECKS_ID) { - context?.let { showThemedToast(it, R.string.cannot_create_subdeck_for_all_decks, true) } - } else { - showSubDeckDialog(deckName) - } + deckHolder.setOnContextAndLongClickListener { + // creating sub deck with parent deck path + showSubDeckDialog(deckName, deckID) true } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt index eef21cf9b651..d09452100fc2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt @@ -25,6 +25,8 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.RecyclerView +import com.ichi2.anki.OnContextAndLongClickListener +import com.ichi2.anki.OnContextAndLongClickListener.Companion.setOnContextAndLongClickListener import com.ichi2.anki.R import com.ichi2.annotations.NeedsTest import com.ichi2.ui.CheckBoxTriStates @@ -224,10 +226,10 @@ class TagsArrayAdapter(private val tags: TagsList, private val resources: Resour private val tagToIsExpanded: HashMap /** - * Long click listener for each tag item. Used to add a subtag for the clicked tag. + * Context and Long click listener for each tag item. Used to add a subtag for the clicked tag. * The full tag is passed through View.tag */ - var tagLongClickListener: View.OnLongClickListener? = null + var tagContextAndLongClickListener: OnContextAndLongClickListener? = null fun sortData() { tags.sort() @@ -266,8 +268,8 @@ class TagsArrayAdapter(private val tags: TagsList, private val resources: Resour vh.checkBoxView.refreshDrawableState() } } - // long clicking a tag opens the add tag dialog with the current tag as the prefix - vh.itemView.setOnLongClickListener(tagLongClickListener) + // context and long clicking a tag opens the add tag dialog with the current tag as the prefix + vh.itemView.setOnContextAndLongClickListener(tagContextAndLongClickListener) return vh } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt index 343c870f0846..404ff8dad6ab 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt @@ -27,6 +27,7 @@ import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.ichi2.anki.OnContextAndLongClickListener import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment import com.ichi2.anki.model.CardStateFilter @@ -189,14 +190,15 @@ class TagsDialog : AnalyticsDialogFragment { dialogTitle = resources.getString(R.string.card_details_tags) optionsGroup.visibility = View.GONE positiveText = getString(R.string.dialog_ok) - tagsArrayAdapter!!.tagLongClickListener = View.OnLongClickListener { v -> - createAddTagDialog(v.tag as String) - true - } + tagsArrayAdapter!!.tagContextAndLongClickListener = + OnContextAndLongClickListener { v -> + createAddTagDialog(v.tag as String) + true + } } else { dialogTitle = resources.getString(R.string.studyoptions_limit_select_tags) positiveText = getString(R.string.select) - tagsArrayAdapter!!.tagLongClickListener = View.OnLongClickListener { false } + tagsArrayAdapter!!.tagContextAndLongClickListener = OnContextAndLongClickListener { false } } adjustToolbar(tagsDialogView) dialog = AlertDialog.Builder(requireActivity()) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt index dd560751dda1..9010cd9dc9a4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt @@ -20,7 +20,6 @@ import android.content.Context import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View -import android.view.View.OnLongClickListener import android.view.ViewGroup import android.widget.Filter import android.widget.Filterable @@ -32,6 +31,8 @@ import androidx.annotation.CheckResult import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.RecyclerView import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.OnContextAndLongClickListener +import com.ichi2.anki.OnContextAndLongClickListener.Companion.setOnContextAndLongClickListener import com.ichi2.anki.R import com.ichi2.libanki.DeckId import com.ichi2.libanki.sched.DeckNode @@ -64,7 +65,7 @@ class DeckAdapter(private val layoutInflater: LayoutInflater, context: Context) // Listeners private var deckClickListener: View.OnClickListener? = null private var deckExpanderClickListener: View.OnClickListener? = null - private var deckLongClickListener: OnLongClickListener? = null + private var deckContextAndLongClickListener: OnContextAndLongClickListener? = null private var countsClickListener: View.OnClickListener? = null // Totals accumulated as each deck is processed @@ -114,8 +115,8 @@ class DeckAdapter(private val layoutInflater: LayoutInflater, context: Context) deckExpanderClickListener = listener } - fun setDeckLongClickListener(listener: OnLongClickListener?) { - deckLongClickListener = listener + fun setDeckContextAndLongClickListener(listener: OnContextAndLongClickListener?) { + deckContextAndLongClickListener = listener } /** Sets whether the control should have partial transparency to allow a background to be seen */ @@ -219,7 +220,7 @@ class DeckAdapter(private val layoutInflater: LayoutInflater, context: Context) // Set click listeners holder.deckLayout.setOnClickListener(deckClickListener) - holder.deckLayout.setOnLongClickListener(deckLongClickListener) + holder.deckLayout.setOnContextAndLongClickListener(deckContextAndLongClickListener) holder.countsLayout.setOnClickListener(countsClickListener) }