-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
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
base: main
Are you sure you want to change the base?
Value class deck #17718
Conversation
b3b8b54
to
3dec54c
Compare
AnkiDroid/src/main/java/com/ichi2/ui/AppCompatPreferenceActivity.kt
Outdated
Show resolved
Hide resolved
3dec54c
to
0f7f2df
Compare
There was a problem hiding this 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
AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt
Outdated
Show resolved
Hide resolved
0f7f2df
to
b5d4aa3
Compare
Test added for each property |
292c595
to
4f95714
Compare
There was a problem hiding this 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 |
There was a problem hiding this comment.
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?
4f95714
to
e95211d
Compare
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.
e95211d
to
c9e9db4
Compare
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.
c9e9db4
to
b7ba1ae
Compare
This comment was marked as resolved.
This comment was marked as resolved.
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.
There was a problem hiding this 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 theFilteredDeck
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 |
There was a problem hiding this comment.
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) {
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.