Skip to content

Commit 9cfc09d

Browse files
committed
Introduce NoteEditorActivity to host the NoteEditorFragment
1 parent f95fc40 commit 9cfc09d

File tree

15 files changed

+350
-169
lines changed

15 files changed

+350
-169
lines changed

AnkiDroid/src/androidTest/java/com/ichi2/anki/NoteEditorIntentTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import com.ichi2.anki.NoteEditorFragment.Companion.intentLaunchedWithImage
2424
import com.ichi2.anki.noteeditor.NoteEditorLauncher
2525
import com.ichi2.anki.tests.InstrumentedTest
2626
import com.ichi2.anki.testutil.GrantStoragePermission
27-
import com.ichi2.anki.testutil.getEditor
27+
import com.ichi2.anki.testutil.getNoteEditorFragment
2828
import com.ichi2.testutils.common.Flaky
2929
import com.ichi2.testutils.common.OS
3030
import com.ichi2.utils.AssetHelper.TEXT_PLAIN
@@ -42,7 +42,7 @@ class NoteEditorIntentTest : InstrumentedTest() {
4242
var runtimePermissionRule: TestRule? = GrantStoragePermission.instance
4343

4444
@get:Rule
45-
var activityRuleIntent: ActivityScenarioRule<SingleFragmentActivity>? =
45+
var activityRuleIntent: ActivityScenarioRule<NoteEditorActivity>? =
4646
ActivityScenarioRule(
4747
noteEditorTextIntent,
4848
)
@@ -56,7 +56,7 @@ class NoteEditorIntentTest : InstrumentedTest() {
5656

5757
var currentFieldStrings: String? = null
5858
scenario.onActivity { activity ->
59-
val editor = activity.getEditor()
59+
val editor = activity.getNoteEditorFragment()
6060
currentFieldStrings = editor.currentFieldStrings[0]
6161
}
6262
MatcherAssert.assertThat(currentFieldStrings!!, Matchers.equalTo("sample text"))

AnkiDroid/src/androidTest/java/com/ichi2/anki/NoteEditorTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ abstract class NoteEditorTest protected constructor() {
4545
.takeUnless { isInvalid }
4646

4747
@get:Rule
48-
var activityRule: ActivityScenarioRule<SingleFragmentActivity>? =
49-
ActivityScenarioRule<SingleFragmentActivity>(
48+
var activityRule: ActivityScenarioRule<NoteEditorActivity>? =
49+
ActivityScenarioRule<NoteEditorActivity>(
5050
noteEditorIntent,
5151
).takeUnless { isInvalid }
5252

AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/NoteEditorFragment.kt

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
package com.ichi2.anki.testutil
1818

1919
import androidx.test.core.app.ActivityScenario
20+
import com.ichi2.anki.NoteEditorActivity
2021
import com.ichi2.anki.NoteEditorFragment
2122
import com.ichi2.anki.R
22-
import com.ichi2.anki.SingleFragmentActivity
2323
import java.util.concurrent.atomic.AtomicReference
2424

2525
/**
@@ -28,11 +28,11 @@ import java.util.concurrent.atomic.AtomicReference
2828
* @throws Throwable if any exception is thrown during the execution of the block.
2929
*/
3030
@Throws(Throwable::class)
31-
fun ActivityScenario<SingleFragmentActivity>.onNoteEditor(block: (NoteEditorFragment) -> Unit) {
31+
fun ActivityScenario<NoteEditorActivity>.onNoteEditor(block: (NoteEditorFragment) -> Unit) {
3232
val wrapped = AtomicReference<Throwable?>(null)
33-
this.onActivity { activity: SingleFragmentActivity ->
33+
this.onActivity { activity: NoteEditorActivity ->
3434
try {
35-
val editor = activity.getEditor()
35+
val editor: NoteEditorFragment = activity.getNoteEditorFragment()
3636
activity.runOnUiThread {
3737
try {
3838
block(editor)
@@ -47,8 +47,5 @@ fun ActivityScenario<SingleFragmentActivity>.onNoteEditor(block: (NoteEditorFrag
4747
wrapped.get()?.let { throw it }
4848
}
4949

50-
/**
51-
* Extension function for SingleFragmentActivity to find the NoteEditor fragment
52-
*/
53-
fun SingleFragmentActivity.getEditor(): NoteEditorFragment =
54-
supportFragmentManager.findFragmentById(R.id.fragment_container) as NoteEditorFragment
50+
fun NoteEditorActivity.getNoteEditorFragment(): NoteEditorFragment =
51+
supportFragmentManager.findFragmentById(R.id.note_editor_fragment_frame) as NoteEditorFragment

AnkiDroid/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,12 @@
452452
android:exported="false"
453453
android:configChanges="orientation|screenSize"
454454
/>
455+
<activity
456+
android:name="com.ichi2.anki.NoteEditorActivity"
457+
android:exported="false"
458+
android:configChanges="keyboardHidden|orientation|screenSize"
459+
android:windowSoftInputMode="adjustResize"
460+
/>
455461
<activity
456462
android:name="com.ichi2.anki.previewer.CardViewerActivity"
457463
android:exported="false"
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright (c) 2025 Hari Srinivasan <harisrini21@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki
18+
19+
import android.content.Context
20+
import android.content.Intent
21+
import android.os.Bundle
22+
import androidx.fragment.app.Fragment
23+
import androidx.fragment.app.commit
24+
import com.ichi2.anki.android.input.ShortcutGroup
25+
import com.ichi2.anki.android.input.ShortcutGroupProvider
26+
import com.ichi2.anki.libanki.Collection
27+
import com.ichi2.anki.noteeditor.NoteEditorLauncher
28+
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
29+
import com.ichi2.anki.snackbar.SnackbarBuilder
30+
import timber.log.Timber
31+
import kotlin.reflect.KClass
32+
import kotlin.reflect.jvm.jvmName
33+
34+
/**
35+
* To find the actual note Editor, @see [NoteEditorFragment]
36+
* This activity contains the NoteEditorFragment, and, on x-large screens, the previewer fragment.
37+
* It also ensures that changes in the note are transmitted to the previewer
38+
*/
39+
40+
// TODO: Move intent handling to [NoteEditorActivity] from [NoteEditorFragment]
41+
class NoteEditorActivity :
42+
AnkiActivity(),
43+
BaseSnackbarBuilderProvider,
44+
DispatchKeyEventListener,
45+
ShortcutGroupProvider {
46+
override val baseSnackbarBuilder: SnackbarBuilder = { }
47+
48+
lateinit var noteEditorFragment: NoteEditorFragment
49+
50+
override fun onCreate(savedInstanceState: Bundle?) {
51+
if (showedActivityFailedScreen(savedInstanceState)) {
52+
return
53+
}
54+
super.onCreate(savedInstanceState)
55+
if (!ensureStoragePermissions()) {
56+
return
57+
}
58+
59+
setContentView(R.layout.note_editor)
60+
61+
/**
62+
* The [NoteEditorActivity] activity supports multiple note editing workflows using fragments.
63+
* It dynamically chooses the appropriate fragment to load and the arguments to pass to it,
64+
* based on intent extras provided at launch time.
65+
*
66+
* - [FRAGMENT_NAME_EXTRA]: Fully qualified name of the fragment class to instantiate.
67+
* If set to [NoteEditorFragment], the activity initializes it with the arguments in
68+
* [FRAGMENT_ARGS_EXTRA].
69+
*
70+
* - [FRAGMENT_ARGS_EXTRA]: Bundle containing parameters for the fragment (e.g. note ID,
71+
* deck ID, etc.). Used to populate fields or determine editor behavior.
72+
*
73+
* This logic is encapsulated in the [launcher] assignment, which selects the correct
74+
* fragment mode (e.g. add note, edit note) based on intent contents.
75+
*/
76+
val launcher =
77+
if (intent.hasExtra(FRAGMENT_NAME_EXTRA)) {
78+
val fragmentClassName = intent.getStringExtra(FRAGMENT_NAME_EXTRA)
79+
if (fragmentClassName == NoteEditorFragment::class.java.name) {
80+
val fragmentArgs = intent.getBundleExtra(FRAGMENT_ARGS_EXTRA)
81+
if (fragmentArgs != null) {
82+
NoteEditorLauncher.PassArguments(fragmentArgs)
83+
} else {
84+
NoteEditorLauncher.AddNote()
85+
}
86+
} else {
87+
NoteEditorLauncher.AddNote()
88+
}
89+
} else {
90+
// Regular NoteEditor intent handling
91+
intent.getBundleExtra(FRAGMENT_ARGS_EXTRA)?.let { fragmentArgs ->
92+
// If FRAGMENT_ARGS_EXTRA is provided, use it directly
93+
NoteEditorLauncher.PassArguments(fragmentArgs)
94+
} ?: intent.extras?.let { bundle ->
95+
// Check if the bundle contains FRAGMENT_ARGS_EXTRA (for launchers that wrap their args)
96+
bundle.getBundle(FRAGMENT_ARGS_EXTRA)?.let { wrappedFragmentArgs ->
97+
NoteEditorLauncher.PassArguments(wrappedFragmentArgs)
98+
} ?: NoteEditorLauncher.PassArguments(bundle)
99+
} ?: NoteEditorLauncher.AddNote()
100+
}
101+
102+
supportFragmentManager.commit {
103+
replace(R.id.note_editor_fragment_frame, NoteEditorFragment.newInstance(launcher), FRAGMENT_TAG)
104+
setReorderingAllowed(true)
105+
/**
106+
* Initializes the noteEditorFragment reference only after the transaction is committed.
107+
* This ensures the fragment is fully created and available in the activity before
108+
* any code attempts to interact with it, preventing potential null reference issues.
109+
*/
110+
runOnCommit {
111+
noteEditorFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as NoteEditorFragment
112+
}
113+
}
114+
115+
startLoadingCollection()
116+
}
117+
118+
override fun onCollectionLoaded(col: Collection) {
119+
super.onCollectionLoaded(col)
120+
Timber.d("onCollectionLoaded()")
121+
registerReceiver()
122+
}
123+
124+
override fun dispatchKeyEvent(event: android.view.KeyEvent): Boolean =
125+
noteEditorFragment.dispatchKeyEvent(event) || super.dispatchKeyEvent(event)
126+
127+
override val shortcuts: ShortcutGroup
128+
get() = noteEditorFragment.shortcuts
129+
130+
companion object {
131+
const val FRAGMENT_ARGS_EXTRA = "fragmentArgs"
132+
const val FRAGMENT_NAME_EXTRA = "fragmentName"
133+
const val FRAGMENT_TAG = "NoteEditorFragmentTag"
134+
135+
/**
136+
* Creates an Intent to launch the NoteEditor activity with a specific fragment class and arguments.
137+
*
138+
* @param context The context from which the intent will be launched
139+
* @param fragmentClass The Kotlin class of the Fragment to instantiate
140+
* @param arguments Optional bundle of arguments to pass to the fragment
141+
* @param intentAction Optional action to set on the intent
142+
* @return An Intent configured to launch NoteEditor with the specified fragment
143+
*/
144+
fun getIntent(
145+
context: Context,
146+
fragmentClass: KClass<out Fragment>,
147+
arguments: Bundle? = null,
148+
intentAction: String? = null,
149+
): Intent =
150+
Intent(context, NoteEditorActivity::class.java).apply {
151+
putExtra(FRAGMENT_NAME_EXTRA, fragmentClass.jvmName)
152+
putExtra(FRAGMENT_ARGS_EXTRA, arguments)
153+
action = intentAction
154+
}
155+
}
156+
}

AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,15 @@ import androidx.core.os.BundleCompat
7070
import androidx.core.text.HtmlCompat
7171
import androidx.core.util.component1
7272
import androidx.core.util.component2
73+
import androidx.core.view.MenuHost
7374
import androidx.core.view.MenuProvider
7475
import androidx.core.view.OnReceiveContentListener
7576
import androidx.core.view.WindowInsetsControllerCompat
7677
import androidx.core.view.isVisible
7778
import androidx.draganddrop.DropHelper
7879
import androidx.fragment.app.Fragment
7980
import androidx.fragment.app.activityViewModels
81+
import androidx.lifecycle.Lifecycle
8082
import androidx.lifecycle.lifecycleScope
8183
import anki.config.ConfigKey
8284
import anki.notes.NoteFieldsCheckResponse
@@ -152,7 +154,6 @@ import com.ichi2.anki.ui.setupNoteTypeSpinner
152154
import com.ichi2.anki.utils.ext.sharedPrefs
153155
import com.ichi2.anki.utils.ext.showDialogFragment
154156
import com.ichi2.anki.utils.ext.window
155-
import com.ichi2.anki.widgets.DeckDropDownAdapter.SubtitleListener
156157
import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat
157158
import com.ichi2.compat.setTooltipTextCompat
158159
import com.ichi2.imagecropper.ImageCropper
@@ -210,7 +211,6 @@ const val CALLER_KEY = "caller"
210211
class NoteEditorFragment :
211212
Fragment(R.layout.note_editor_fragment),
212213
DeckSelectionListener,
213-
SubtitleListener,
214214
TagsDialogListener,
215215
BaseSnackbarBuilderProvider,
216216
DispatchKeyEventListener,
@@ -227,7 +227,7 @@ class NoteEditorFragment :
227227
get() = CollectionManager.getColUnsafe()
228228

229229
private val mainToolbar: androidx.appcompat.widget.Toolbar
230-
get() = requireView().findViewById(R.id.toolbar)
230+
get() = requireAnkiActivity().findViewById(R.id.toolbar)
231231

232232
/**
233233
* Flag which forces the calling activity to rebuild it's definition of current card from scratch
@@ -441,9 +441,6 @@ class NoteEditorFragment :
441441
}
442442
}
443443

444-
override val subtitleText: String
445-
get() = ""
446-
447444
private enum class AddClozeType {
448445
SAME_NUMBER,
449446
INCREMENT_NUMBER,
@@ -579,7 +576,13 @@ class NoteEditorFragment :
579576
requireActivity().onBackPressedDispatcher.onBackPressed()
580577
}
581578

579+
// Register this fragment as a menu provider with the activity
582580
mainToolbar.addMenuProvider(this)
581+
(requireActivity() as MenuHost).addMenuProvider(
582+
this,
583+
viewLifecycleOwner,
584+
Lifecycle.State.RESUMED,
585+
)
583586
}
584587

585588
/**

AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorLauncher.kt

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import android.os.Parcelable
2424
import androidx.core.os.bundleOf
2525
import com.ichi2.anim.ActivityTransitionAnimation
2626
import com.ichi2.anki.AnkiActivity
27+
import com.ichi2.anki.NoteEditorActivity
2728
import com.ichi2.anki.NoteEditorFragment
2829
import com.ichi2.anki.NoteEditorFragment.Companion.NoteEditorCaller
29-
import com.ichi2.anki.SingleFragmentActivity
3030
import com.ichi2.anki.browser.CardBrowserViewModel
3131
import com.ichi2.anki.libanki.CardId
3232
import com.ichi2.anki.libanki.DeckId
@@ -39,16 +39,19 @@ sealed interface NoteEditorLauncher : Destination {
3939
override fun toIntent(context: Context): Intent = toIntent(context, action = null)
4040

4141
/**
42-
* Generates an intent to open the NoteEditor fragment with the configured parameters.
42+
* Generates an intent to open the NoteEditor activity with the configured parameters
4343
*
4444
* @param context The context from which the intent is launched.
4545
* @param action Optional action string for the intent.
46-
* @return Intent configured to launch the NoteEditor fragment.
46+
* @return Intent configured to launch the NoteEditor activity.
4747
*/
4848
fun toIntent(
4949
context: Context,
5050
action: String? = null,
51-
) = SingleFragmentActivity.getIntent(context, NoteEditorFragment::class, toBundle(), action)
51+
) = Intent(context, NoteEditorActivity::class.java).apply {
52+
putExtras(toBundle())
53+
action?.let { this.action = it }
54+
}
5255

5356
/**
5457
* Converts the configuration into a Bundle to pass arguments to the NoteEditor fragment.
@@ -105,16 +108,18 @@ sealed interface NoteEditorLauncher : Destination {
105108
val inFragmentedActivity: Boolean = false,
106109
) : NoteEditorLauncher {
107110
override fun toBundle(): Bundle {
108-
val bundle =
111+
val fragmentArgs =
109112
bundleOf(
110113
NoteEditorFragment.EXTRA_CALLER to NoteEditorCaller.CARDBROWSER_ADD.value,
111114
NoteEditorFragment.EXTRA_TEXT_FROM_SEARCH_VIEW to viewModel.searchTerms,
112115
NoteEditorFragment.IN_FRAGMENTED_ACTIVITY to inFragmentedActivity,
113116
)
114117
if (viewModel.lastDeckId?.let { id -> id > 0 } == true) {
115-
bundle.putLong(NoteEditorFragment.EXTRA_DID, viewModel.lastDeckId!!)
118+
fragmentArgs.putLong(NoteEditorFragment.EXTRA_DID, viewModel.lastDeckId!!)
116119
}
117-
return bundle
120+
return bundleOf(
121+
NoteEditorActivity.FRAGMENT_ARGS_EXTRA to fragmentArgs,
122+
)
118123
}
119124
}
120125

@@ -125,17 +130,23 @@ sealed interface NoteEditorLauncher : Destination {
125130
data class AddNoteFromReviewer(
126131
val animation: ActivityTransitionAnimation.Direction? = null,
127132
) : NoteEditorLauncher {
128-
override fun toBundle(): Bundle =
129-
bundleOf(
130-
NoteEditorFragment.EXTRA_CALLER to NoteEditorCaller.REVIEWER_ADD.value,
131-
).also { bundle ->
132-
animation?.let { animation ->
133-
bundle.putParcelable(
134-
AnkiActivity.FINISH_ANIMATION_EXTRA,
135-
animation as Parcelable,
136-
)
133+
override fun toBundle(): Bundle {
134+
val fragmentArgs =
135+
bundleOf(
136+
NoteEditorFragment.EXTRA_CALLER to NoteEditorCaller.REVIEWER_ADD.value,
137+
).also { bundle ->
138+
animation?.let { animation ->
139+
bundle.putParcelable(
140+
AnkiActivity.FINISH_ANIMATION_EXTRA,
141+
animation as Parcelable,
142+
)
143+
}
137144
}
138-
}
145+
146+
return bundleOf(
147+
NoteEditorActivity.FRAGMENT_ARGS_EXTRA to fragmentArgs,
148+
)
149+
}
139150
}
140151

141152
/**

0 commit comments

Comments
 (0)