Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Value class deck #17718

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

Arthur-Milchior
Copy link
Member

I loved David's recent work in using value class. And thought that we could finally kill the overhead cost of DeepClone in JSONObject. So I transformed Deck into a value class, and added the needed properties so that the other part of the code can use Deck without considering it as a JSONObject.

@mikehardy mikehardy added the Needs Author Reply Waiting for a reply from the original author label Jan 6, 2025
@Arthur-Milchior Arthur-Milchior added Needs Review and removed Needs Author Reply Waiting for a reply from the original author Has Conflicts labels Jan 8, 2025
Copy link
Member

@david-allison david-allison left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One question on the 1 and 2 discrepancy

@david-allison david-allison added Needs Author Reply Waiting for a reply from the original author and removed Needs Review labels Jan 16, 2025
@Arthur-Milchior Arthur-Milchior removed Needs Author Reply Waiting for a reply from the original author Has Conflicts labels Jan 16, 2025
@Arthur-Milchior
Copy link
Member Author

Test added for each property

@Arthur-Milchior Arthur-Milchior force-pushed the value_class_deck branch 2 times, most recently from 292c595 to 4f95714 Compare January 16, 2025 23:52
Copy link
Member

@david-allison david-allison left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typings for a number of properties are incorrect, and I'm concerned.

previewGoodSecs is an Int. if you insert it into JSON as a String, it's quoted

}

"limit" -> {
ar.getJSONArray(0).put(1, value)
deck.firstFilter.limit = value as String
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a string?

I had a bug due to an erroneous type sent to `toJsonBytes`. I believe
it's better to indicates explicitly which type we know it can accept,
and if needed add more type.
The first interest of this change is that we are not deep cloning all
decks when creating a Deck object which should save a little bit of
time, especially loading time on collection with a lot of decks.

Instead, we have a better typing at zero runtime cost.

The second interest is that we are certain that the fact that the Deck
is implemented as a JSONObject is hidden from the developer using this
class. Instead, they can only use the provided extension, or add the
one they needs.

While most changes are relatively trivial, moving the implementation
from everywhere in the code to Deck.kt, there are some extra changes
that were needed.

I moved `getLongOrNull` to PythonExtension given that it implements
the behavior of a Python function we want to reuse, this seems more
consistent.

As value class can't be lateinit, I hardcoded
AppCompatPreferenceActivity as being a getter for the backing field
`_deck: Deck?`.

I added in testutils/JsonUtils helper method that accepts a json
holder, and a string representing the expected json, to simplify the
use of those methods.

The code used sometime `optLong("id")` and sometime
`getLong("id")`. There seems to be no reason for the difference
between both use case, and just be random choice from the
developer. Anyway, if a deck lacks an id, any place using
`getLong("id")` would have caused the code to crash, so replacing the
`optLong` by `getLong` would, in the worst case, move the time at
which the user is asked to clean their database.

There seems to be a single key we sometime set to JSONObject.NULL, it
was `delays`. For the sake of typing, the property `delays` in `deck`
is thus of type `JSONArray?`. When the value `null` is assigned to
this property, `NULL` is saved in the object, and reciprocally.

There is no need to check whether `deck` has "empty" key before
removing it. It's not even more efficient.
@Arthur-Milchior

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Jan 17, 2025
Deck is now declared as an interface so that it can be
inherited. `Deck` contains a facotry to decide whether to return a
normal deck or a filtered one.
@david-allison david-allison added Needs Review and removed Needs Author Reply Waiting for a reply from the original author labels Jan 18, 2025
@david-allison david-allison self-requested a review January 18, 2025 03:12
Copy link
Member

@david-allison david-allison left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My comment on some properties being incorrectly typed as string stands, and these properties are now broken.

In addition: the failure case of opening a non-filtered deck is now broken.

I provided this partial patch to add regression cover over Discord (copied here), and I'm waiting to see:

  • Should some of this be merged on main, and the rest merged here
  • Should this patch be modified to run tests on main, be merged, and then be modified in this PR to handle the FilteredDeck updates
Subject: [PATCH] aa
---
Index: AnkiDroid/src/main/java/com/ichi2/libanki/FilteredDeck.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/FilteredDeck.kt b/AnkiDroid/src/main/java/com/ichi2/libanki/FilteredDeck.kt
--- a/AnkiDroid/src/main/java/com/ichi2/libanki/FilteredDeck.kt	(revision 230d65cf9d6ef0caa8be9bd9dc4272d9a4990c12)
+++ b/AnkiDroid/src/main/java/com/ichi2/libanki/FilteredDeck.kt	(date 1737171568837)
@@ -91,4 +91,6 @@
         set(value) {
             jsonObject.put("terms", value)
         }
+
+    override fun toString(): String = jsonObject.toString()
 }
Index: AnkiDroid/src/main/java/com/ichi2/anki/FilteredDeckOptions.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/FilteredDeckOptions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/FilteredDeckOptions.kt
--- a/AnkiDroid/src/main/java/com/ichi2/anki/FilteredDeckOptions.kt	(revision 230d65cf9d6ef0caa8be9bd9dc4272d9a4990c12)
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/FilteredDeckOptions.kt	(date 1737172053873)
@@ -19,6 +19,8 @@
 
 package com.ichi2.anki
 
+import android.content.Context
+import android.content.Intent
 import android.content.SharedPreferences
 import android.os.Bundle
 import android.preference.CheckBoxPreference
@@ -30,6 +32,7 @@
 import com.ichi2.anki.analytics.UsageAnalytics
 import com.ichi2.annotations.NeedsTest
 import com.ichi2.libanki.Collection
+import com.ichi2.libanki.DeckId
 import com.ichi2.libanki.FilteredDeck
 import com.ichi2.libanki.FilteredDeck.Term
 import com.ichi2.preferences.StepsPreference.Companion.convertFromJSON
@@ -214,8 +217,10 @@
         deck = if (extras?.containsKey("did") == true) {
             col.decks.get(extras.getLong("did"))
         } else {
+            Timber.d("no deckId supplied. Using current deck")
             null
         } ?: col.decks.current()
+        Timber.i("opened for deck %d", deck.id)
         registerExternalStorageListener()
         if (filteredDeck.isNormal) {
             Timber.w("Deck is not a dyn filteredDeck")
@@ -385,4 +390,14 @@
                 true
             }
     }
+
+    companion object {
+        fun createIntent(
+            context: Context,
+            did: DeckId,
+        ): Intent =
+            Intent(context, FilteredDeckOptions::class.java).apply {
+                putExtra("did", did)
+            }
+    }
 }
Index: AnkiDroid/src/main/java/com/ichi2/ui/AppCompatPreferenceActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/AppCompatPreferenceActivity.kt b/AnkiDroid/src/main/java/com/ichi2/ui/AppCompatPreferenceActivity.kt
--- a/AnkiDroid/src/main/java/com/ichi2/ui/AppCompatPreferenceActivity.kt	(revision 230d65cf9d6ef0caa8be9bd9dc4272d9a4990c12)
+++ b/AnkiDroid/src/main/java/com/ichi2/ui/AppCompatPreferenceActivity.kt	(date 1737170631625)
@@ -34,6 +34,7 @@
 import android.view.View
 import android.view.ViewGroup
 import androidx.annotation.LayoutRes
+import androidx.annotation.VisibleForTesting
 import androidx.appcompat.app.ActionBar
 import androidx.appcompat.app.AlertDialog
 import androidx.appcompat.app.AppCompatDelegate
@@ -79,7 +80,9 @@
     private lateinit var unmountReceiver: BroadcastReceiver
     protected lateinit var col: Collection
         private set
-    protected lateinit var pref: PreferenceHack
+
+    @VisibleForTesting
+    internal lateinit var pref: PreferenceHack
 
     // value class can't be lateinit.
     // Instead we use a backing field.
Index: AnkiDroid/src/test/java/com/ichi2/anki/FilteredDeckOptionsTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/FilteredDeckOptionsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/FilteredDeckOptionsTest.kt
new file mode 100644
--- /dev/null	(date 1737172511028)
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/FilteredDeckOptionsTest.kt	(date 1737172511028)
@@ -0,0 +1,92 @@
+/*
+ *  Copyright (c) 2025 David Allison <[email protected]>
+ *
+ *  This program is free software; you can redistribute it and/or modify it under
+ *  the terms of the GNU General Public License as published by the Free Software
+ *  Foundation; either version 3 of the License, or (at your option) any later
+ *  version.
+ *
+ *  This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ *  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ *  PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License along with
+ *  this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package com.ichi2.anki
+
+import androidx.core.content.edit
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.libanki.Consts
+import com.ichi2.libanki.DeckId
+import com.ichi2.libanki.FilteredDeck
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.equalTo
+import org.hamcrest.Matchers.not
+import org.intellij.lang.annotations.Language
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FilteredDeckOptionsTest : RobolectricTest() {
+    @Test
+    fun `integration test`() {
+        @Language("JSON")
+        val expected = FilteredDeck("""{"id":1737164378146,"mod":1737164378,"name":"Filtered","usn":-1,"lrnToday":[0,0],"revToday":[0,0],"newToday":[0,0],"timeToday":[0,0],"collapsed":false,"browserCollapsed":false,"desc":"","dyn":1,"resched":true,"terms":[["",100,0]],"separate":true,"delays":null,"previewDelay":0,"previewAgainSecs":60,"previewHardSecs":600,"previewGoodSecs":0}""")
+
+        filteredDeckConfig.toConstantString().also { deckOptions ->
+            assertThat("should not be using default deck", filteredDeckConfig.id, not(equalTo(Consts.DEFAULT_DECK_ID)))
+            assertThat("before", deckOptions, equalTo(expected.toConstantString()))
+        }
+
+        withFilteredDeckOptions(newFilteredDeckId) {
+            pref.edit(commit = true) {
+                // TODO(Arthur): fill in the other properties
+                putInt("previewAgainSecs", 1)
+                putInt("previewGoodSecs", 1)
+                putInt("previewHardSecs", 1)
+            }
+        }
+
+        filteredDeckConfig.toConstantString().also { deckOptions ->
+            val updatedExpectation = expected.copyWith {
+                // TODO(Arthur): fill in with changed values to assert on
+                put("previewAgainSecs", 1)
+                put("previewGoodSecs", 1)
+                put("previewHardSecs", 1)
+            }.toConstantString()
+            assertThat("after", deckOptions, equalTo(updatedExpectation))
+        }
+    }
+
+    fun FilteredDeck.toConstantString() =
+        FilteredDeck(this.toString())
+            .apply {
+                jsonObject.remove("id")
+                jsonObject.remove("mod")
+            }.toString()
+
+    fun FilteredDeck.copyWith(block: JSONObject.() -> Unit) = FilteredDeck(this.toString()).apply {
+        jsonObject.apply(block)
+    }
+
+    private fun withFilteredDeckOptions(
+        deckId: DeckId,
+        block: FilteredDeckOptions.() -> Unit,
+    ) {
+        startRegularActivity<FilteredDeckOptions>(FilteredDeckOptions.createIntent(targetContext, deckId)).apply(block)
+    }
+
+    private val filteredDeckConfig
+        get() =
+            newFilteredDeckId.let { did ->
+                col.decks.get(did) as FilteredDeck
+            }
+
+    private val newFilteredDeckId by lazy { col.decks.newFiltered("Filtered") }
+
+    private val defaultDeckConfig
+        get() = col.decks.configDictForDeckId(Consts.DEFAULT_DECK_ID)
+}
Index: AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt	(revision 230d65cf9d6ef0caa8be9bd9dc4272d9a4990c12)
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt	(date 1737171272541)
@@ -2364,8 +2364,7 @@
         // open deck options
         if (getColUnsafe.decks.isFiltered(did)) {
             // open cram options if filtered deck
-            val i = Intent(this@DeckPicker, FilteredDeckOptions::class.java)
-            i.putExtra("did", did)
+            val i = FilteredDeckOptions.createIntent(this, did = did)
             startActivity(i)
         } else {
             // otherwise open regular options
Index: AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
--- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt	(revision 230d65cf9d6ef0caa8be9bd9dc4272d9a4990c12)
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt	(date 1737170631622)
@@ -17,6 +17,7 @@
 package com.ichi2.anki
 
 import android.Manifest
+import android.app.Activity
 import android.app.Application
 import android.content.Context
 import android.content.Intent
@@ -24,6 +25,7 @@
 import android.os.Looper
 import android.widget.TextView
 import androidx.annotation.CallSuper
+import androidx.annotation.CheckResult
 import androidx.appcompat.app.AlertDialog
 import androidx.core.content.edit
 import androidx.sqlite.db.SupportSQLiteOpenHelper
@@ -294,7 +296,7 @@
         }
 
         @JvmStatic // Using protected members which are not @JvmStatic in the superclass companion is unsupported yet
-        protected fun <T : AnkiActivity?> startActivityNormallyOpenCollectionWithIntent(
+        protected fun <T : Activity?> startActivityNormallyOpenCollectionWithIntent(
             testClass: RobolectricTest,
             clazz: Class<T>?,
             i: Intent?,
@@ -368,14 +370,15 @@
         return NotetypeJson(collectionModels.byName(modelName).toString().trim { it <= ' ' })
     }
 
-    internal fun <T : AnkiActivity?> startActivityNormallyOpenCollectionWithIntent(
+    internal fun <T : Activity?> startActivityNormallyOpenCollectionWithIntent(
         clazz: Class<T>?,
         i: Intent?,
     ): T = startActivityNormallyOpenCollectionWithIntent(this, clazz, i)
 
-    internal inline fun <reified T : AnkiActivity?> startRegularActivity(): T = startRegularActivity(null)
+    @CheckResult
+    internal inline fun <reified T : Activity?> startRegularActivity(): T = startRegularActivity(null)
 
-    internal inline fun <reified T : AnkiActivity?> startRegularActivity(i: Intent? = null): T =
+    internal inline fun <reified T : Activity?> startRegularActivity(i: Intent? = null): T =
         startActivityNormallyOpenCollectionWithIntent(T::class.java, i)
 
     /**

@@ -45,6 +46,8 @@ class FilteredDeckOptions :
AppCompatPreferenceActivity<FilteredDeckOptions.DeckPreferenceHack>(),
SharedPreferences.OnSharedPreferenceChangeListener {
private var allowCommit = true
val filteredDeck: FilteredDeck
get() = super.deck as FilteredDeck
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following call in onCreate now crashes, rather than returning false.

You want deck.isNormal

        if (filteredDeck.isNormal) {

@david-allison david-allison added Needs Author Reply Waiting for a reply from the original author and removed Needs Review labels Jan 18, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Author Reply Waiting for a reply from the original author
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants