diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt index 0f2093d69a44..f22833bb5aca 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt @@ -10,12 +10,12 @@ import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.R import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.NoteTypeFieldEditorContextMenuAction import com.ichi2.anki.tests.InstrumentedTest import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith +import com.ichi2.anki.common.android.R as CommonR /** Tests [NoteTypeFieldEditorContextMenu] */ @RunWith(AndroidJUnit4::class) @@ -27,7 +27,7 @@ class NoteTypeFieldEditorContextMenuTest : InstrumentedTest() { fun showsAllOptions() { launchFragment( fragmentArgs = Bundle().apply { putString(NoteTypeFieldEditorContextMenu.ARG_LABEL, testDialogTitle) }, - themeResId = R.style.Theme_Light, + themeResId = CommonR.style.Theme_Light, ) { NoteTypeFieldEditorContextMenu() } onView(withText(testDialogTitle)) .inRoot(isDialog()) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 493ec5298b52..22b293965cea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -26,6 +26,7 @@ import com.ichi2.anki.AnkiDroidApp.Companion.sharedPreferencesTestingOverride import com.ichi2.anki.analytics.initializeAnalytics import com.ichi2.anki.analytics.launchPreviousAnrReporting import com.ichi2.anki.ankiquest.Ankiquest +import com.ichi2.anki.browser.IdsFile import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository import com.ichi2.anki.common.android.AdaptionUtil import com.ichi2.anki.common.android.Animations @@ -41,6 +42,7 @@ import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.utils.android.SdCard +import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.compat.CompatHelper @@ -56,6 +58,7 @@ import com.ichi2.anki.logging.RobolectricDebugTree import com.ichi2.anki.logging.logActivityCreation import com.ichi2.anki.model.FieldFilters.NoSuggestFilter import com.ichi2.anki.multimedia.MultimediaArgsStorage +import com.ichi2.anki.multiprofile.ProfileContextWrapper import com.ichi2.anki.multiprofile.ProfileManager import com.ichi2.anki.multiprofile.isPhoenixProcess import com.ichi2.anki.navigation.initializeNavigator @@ -137,7 +140,7 @@ open class AnkiDroidApp : @set:VisibleForTesting var profileManager: ProfileManager? = null - private val profileContext: Context? + private val profileContext: ProfileContextWrapper? get() = profileManager?.activeProfileContext /** @@ -206,7 +209,7 @@ open class AnkiDroidApp : LogType.PRODUCTION -> Timber.plant(ProductionCrashReportingTree()) } Timber.plant(ReminderLogTree(this)) - if (BuildConfig.ENABLE_LEAK_CANARY) { + if (BuildConfig.ENABLE_LEAK_CANARY && !isRobolectric) { LeakCanaryConfiguration.setInitialConfigFor(this) } else { LeakCanaryConfiguration.disable() @@ -280,17 +283,31 @@ open class AnkiDroidApp : super.onTerminate() } - /** Schedules cache maintenance and logs its duration without delaying startup. */ - private fun launchCacheCleanup() = + /** Schedules temporary-file maintenance and logs its duration without delaying startup. */ + @VisibleForTesting + internal fun launchCacheCleanup() = applicationScope.launch(Dispatchers.IO) { - runCatching { - measureTime("cacheCleanup") { + measureTime("cacheCleanup") { + runCacheCleanup("multimediaArgsCleanup") { MultimediaArgsStorage.create(this@AnkiDroidApp).removeExpiredFiles() + } + runCacheCleanup("voiceRecordingsCleanup") { VoiceRecorder.removeExpiredRecordings(VoiceRecorder.recordingsDirectory(this@AnkiDroidApp)) } - }.onFailure { Timber.w(it, "Unable to clean up expired cache files") } + runCacheCleanup("idsFileCleanup") { + IdsFile.deleteStaleFiles(this@AnkiDroidApp) + } + } } + private inline fun runCacheCleanup( + name: String, + block: () -> Unit, + ) { + runCatching { measureTime(name, block) } + .onFailure { Timber.w(it, "Unable to run %s", name) } + } + /** * @param debugTraceSqlCalls Log all SQL statements executed by the backend. * **Warning** The log may be delayed by 100ms, so you should not assume than a given SQL diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index b562c562a452..021ba39983fd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -653,7 +653,7 @@ open class CardBrowser : viewModelStore, CardBrowserViewModel.factory( lastDeckIdRepository = AnkiDroidApp.instance.sharedPrefsLastDeckIdRepository, - idsFileDirectory = IdsFile.getDirectory(this), + idsFileDirectory = IdsFile.getDirectory(), options = launchOptions, isFragmented = fragmented, initialDeckId = intent?.getLongExtra(CardBrowserViewModel.EXTRA_DECK_ID), diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt b/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt index 140b54924908..9d65e768b71e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt @@ -234,7 +234,7 @@ private fun ensureBrowserViewModel() { deckPicker.viewModelStore, CardBrowserViewModel.factory( lastDeckIdRepository = AnkiDroidApp.instance.sharedPrefsLastDeckIdRepository, - idsFileDirectory = IdsFile.getDirectory(deckPicker), + idsFileDirectory = IdsFile.getDirectory(), options = null, isFragmented = false, initialDeckId = null, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index b6eb5313e5f7..98f9c5a0dbf3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -20,6 +20,7 @@ import android.widget.ScrollView import android.widget.TextView import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.constraintlayout.widget.Group import androidx.core.text.HtmlCompat @@ -48,6 +49,7 @@ import com.ichi2.anki.filtered.FilteredDeckOptionsFragment import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Decks import com.ichi2.anki.observability.ChangeManager +import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.doOnApplyWindowInsets @@ -114,6 +116,7 @@ class StudyOptionsFragment : displayedDeckId = savedInstanceState?.getLong(STATE_DISPLAYED_DECK_ID)?.takeIf { it != 0L } // Subscribe once per fragment: returning from another panel can recreate its view. ChangeManager.subscribe(this) + observeProgress(viewModel) { progress -> getString(progress.messageRes) } } override fun onCreateView( @@ -289,20 +292,12 @@ class StudyOptionsFragment : } R.id.action_rebuild -> { Timber.i("StudyOptionsFragment:: rebuild cram deck button pressed") - launchCatchingTask { - withProgress(CommonString.rebuild_filtered_deck) { - viewModel.rebuildCram() - } - } + launchCatchingTask { viewModel.rebuildCram() } return true } R.id.action_empty -> { Timber.i("StudyOptionsFragment:: empty cram deck button pressed") - launchCatchingTask { - withProgress(CommonString.empty_filtered_deck) { - viewModel.emptyCram() - } - } + launchCatchingTask { viewModel.emptyCram() } return true } else -> return false @@ -571,3 +566,11 @@ class StudyOptionsFragment : } } } + +@get:StringRes +private val StudyOptionsProgress.messageRes: Int + get() = + when (this) { + StudyOptionsProgress.REBUILDING_FILTERED_DECK -> CommonString.rebuild_filtered_deck + StudyOptionsProgress.EMPTYING_FILTERED_DECK -> CommonString.empty_filtered_deck + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt index bbefbe85112f..5b175cab7653 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt @@ -10,6 +10,8 @@ import com.ichi2.anki.CollectionManager.withOpenColOrNull import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.undoableOp +import com.ichi2.anki.progress.HasProgress +import com.ichi2.anki.progress.ProgressManager import com.ichi2.anki.study.DeckKind import com.ichi2.anki.study.StudyAvailability import com.ichi2.anki.study.currentDeckStudyState @@ -20,7 +22,11 @@ import kotlinx.coroutines.launch import timber.log.Timber import java.io.File -class StudyOptionsViewModel : ViewModel() { +class StudyOptionsViewModel : + ViewModel(), + HasProgress { + override val progressManager = ProgressManager() + val flowOfState: StateFlow field = MutableStateFlow(StudyOptionsState.Loading) val state: StudyOptionsState get() = flowOfState.value @@ -52,14 +58,18 @@ class StudyOptionsViewModel : ViewModel() { suspend fun rebuildCram() { Timber.d("doInBackground - RebuildCram") - undoableOp { sched.rebuildFilteredDeck(decks.selected()) } - withCol { updateStateFromCollection() } + progressManager.withProgress(message = StudyOptionsProgress.REBUILDING_FILTERED_DECK) { + undoableOp { sched.rebuildFilteredDeck(decks.selected()) } + withCol { updateStateFromCollection() } + } } suspend fun emptyCram() { Timber.d("doInBackgroundEmptyCram") - undoableOp { sched.emptyFilteredDeck(decks.selected()) } - withCol { updateStateFromCollection() } + progressManager.withProgress(message = StudyOptionsProgress.EMPTYING_FILTERED_DECK) { + undoableOp { sched.emptyFilteredDeck(decks.selected()) } + withCol { updateStateFromCollection() } + } } fun unbury(): Job = @@ -138,6 +148,11 @@ class StudyOptionsViewModel : ViewModel() { } } +enum class StudyOptionsProgress { + REBUILDING_FILTERED_DECK, + EMPTYING_FILTERED_DECK, +} + class DeckStudyData( val newCardsToday: Int, val lrnCardsToday: Int, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt index 385032dfec40..13d0e34321ec 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnSelectionAdapter.kt @@ -125,7 +125,7 @@ class BrowserColumnSelectionAdapter( binding.buttonToggleColumn.apply { // NICE_TO_HAVE: animate between + and - val isExclude = absoluteAdapterPosition < positionOfAvailableHeading - setImageResource(if (isExclude) R.drawable.ic_remove else CommonR.drawable.ic_add) + setImageResource(if (isExclude) CommonR.drawable.ic_remove else CommonR.drawable.ic_add) val label = context.getString(if (isExclude) CommonString.exclude_column else CommonString.include_column) contentDescription = label diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index dc7e34e8ee99..04e5290c5497 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -1702,7 +1702,7 @@ class CardBrowserFragment : val (type, selectedIds) = activityViewModel.querySelectionExportData() ?: return ExportDialogFragment .newInstance( - IdsFile.getDirectory(requireContext()), + IdsFile.getDirectory(), type, selectedIds, ).show(parentFragmentManager, "exportDialog") diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index ad107ed857e3..d2214ff93a9a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -556,13 +556,12 @@ class CardBrowserViewModel( multiselectStateFile = idsFile pendingSelectionRestore = try { - idsFile?.getIds()?.map { CardOrNoteId(it) } + idsFile?.getIdsOrNull()?.map { CardOrNoteId(it) } } catch (e: Exception) { - // #19572: I suspect we have a startup bug here, so continue reporting the exception Timber.w(e, "failed to read STATE_MULTISELECT_VALUES") CrashReportService.sendExceptionReport( e = e, - origin = "19572: STATE_MULTISELECT_VALUES", + origin = "CardBrowserViewModel", onlyIfSilent = true, ) // fallback to no selections, but still in multiselect mode diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt index 85bbd31be294..30dc458793a5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt @@ -99,9 +99,8 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { (dialog as? AlertDialog)?.positiveButton?.isEnabled = false binding.contentViewsGroup.isVisible = false binding.loadingViewsGroup.isVisible = true - val fetchNoteIdsResult = runCatching { idsFile.getIds() } - val noteIds = fetchNoteIdsResult.getOrNull() - if (fetchNoteIdsResult.isFailure || noteIds == null) { + val noteIds = runCatching { idsFile.getIdsOrNull() }.getOrNull() + if (noteIds == null) { requireActivity().showSnackbar(CommonString.something_wrong) dismiss() return@launch @@ -190,7 +189,7 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { context: Context, noteIds: List, ): FindAndReplaceDialogFragment { - val file = IdsFile(IdsFile.getDirectory(context), noteIds, IdsFile.Purpose.FIND_AND_REPLACE) + val file = IdsFile(IdsFile.getDirectory(), noteIds, IdsFile.Purpose.FIND_AND_REPLACE) return FindAndReplaceDialogFragment().apply { arguments = Bundle().apply { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt index dfbaffd6afba..d83e2abfff0b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt @@ -5,13 +5,19 @@ package com.ichi2.anki.browser import android.content.Context import android.os.Parcel import android.os.Parcelable +import androidx.annotation.VisibleForTesting +import com.ichi2.anki.common.android.appContext +import com.ichi2.anki.common.time.TimeManager +import com.ichi2.utils.FileUtil import timber.log.Timber import java.io.DataInputStream import java.io.DataOutputStream import java.io.File import java.io.FileInputStream +import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException +import kotlin.time.Duration.Companion.days /** * Temporary file containing cards or note IDs to be passed in a Bundle. @@ -31,7 +37,9 @@ class IdsFile( * @param directory parent directory of the file. Generally it should be [getDirectory] * @param ids ids to store */ - constructor(directory: File, ids: List, purpose: Purpose) : this(path = createTempFile(purpose.prefix, ".tmp", directory).path) { + constructor(directory: File, ids: List, purpose: Purpose) : this( + path = createTempFile(purpose.prefix, ".tmp", ensureDirectory(directory)).path, + ) { DataOutputStream(FileOutputStream(this)).use { outputStream -> outputStream.writeInt(ids.size) for (id in ids) { @@ -45,7 +53,7 @@ class IdsFile( * * @throws IOException if the file is missing or corrupt. */ - fun getIds(): List = + private fun readIds(): List = FileInputStream(this).use { fileStream -> val inputStream = DataInputStream(fileStream.buffered()) val size = inputStream.readInt() @@ -57,6 +65,20 @@ class IdsFile( List(size) { inputStream.readLong() } } + /** + * Reads the IDs, or returns null if the snapshot is no longer available. + * An empty snapshot returns an empty list. + * + * @throws IOException if the file is corrupt or reading fails after opening it. + */ + fun getIdsOrNull(): List? = + try { + readIds() + } catch (_: FileNotFoundException) { + Timber.w("ID snapshot is no longer available: %s", this) + null + } + override fun describeContents(): Int = 0 override fun writeToParcel( @@ -79,7 +101,66 @@ class IdsFile( } companion object { - fun getDirectory(context: Context): File = context.cacheDir + private const val DIRECTORY_NAME = "idsfiles" + + /** Maximum age of a saved selection, including files still referenced by saved state. */ + private val FILE_RETENTION = 7.days + + /** + * Deletes snapshots older than [FILE_RETENTION], mostly files abandoned after process death. + * + * Called at startup on an IO dispatcher. + * + * Note: There is a slim chance that this will clear active snapshots, so downstream code + * must handle this possibility. + */ + fun deleteStaleFiles(context: Context = appContext) { + // Do not create the snapshot directory just to clean it. + deleteStaleFiles(File(context.cacheDir, DIRECTORY_NAME)) + } + + @VisibleForTesting + internal fun deleteStaleFiles(directory: File) { + val cutoff = TimeManager.time.intTimeMS() - FILE_RETENTION.inWholeMilliseconds + val deleteCount = + FileUtil.listFilesOrEmpty(directory).count { file -> + file.withCleanupErrorHandling { + file.isStale(cutoff) && file.deleteWithLogging() + } + } + Timber.i("Deleted %d expired ID snapshot(s)", deleteCount) + } + + private fun File.isStale(cutoff: Long): Boolean { + val modified = lastModified() + return isFile && modified > 0 && modified < cutoff + } + + private fun File.deleteWithLogging(): Boolean = + delete().also { deleted -> + if (!deleted) Timber.w("Unable to delete expired ID snapshot: %s", this) + } + + private inline fun File.withCleanupErrorHandling(block: () -> Boolean): Boolean = + try { + block() + } catch (e: SecurityException) { + Timber.w(e, "Unable to access ID snapshot path: %s", this) + false + } + + /** + * Returns the cache path for saved selections. The directory is created when writing a snapshot. + * Android may evict these files at any age; callers must continue handling missing snapshots. + */ + fun getDirectory(context: Context = appContext): File = File(context.cacheDir, DIRECTORY_NAME) + + private fun ensureDirectory(directory: File): File { + if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) { + throw IOException("Unable to create ID snapshot directory: $directory") + } + return directory + } @JvmField @Suppress("unused") diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt index 1a131aa6ed40..0ed094a1d78c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt @@ -23,6 +23,7 @@ import com.ichi2.anki.R import com.ichi2.anki.browser.SearchHistory.SearchHistoryEntry import com.ichi2.anki.browser.search.CardBrowserSearchViewModel.SearchHistoryItems import com.ichi2.anki.browser.toUserSpannable +import com.ichi2.anki.common.utils.ellipsize import com.ichi2.anki.databinding.FragmentStandardSearchBinding import com.ichi2.anki.databinding.ViewSavedSearchItemBinding import com.ichi2.anki.databinding.ViewSearchHistoryItemBinding @@ -263,8 +264,9 @@ class StandardSearchFragment : } val item = getItem(position)!! - binding.title.text = item.name - binding.content.text = item.query + // Bound the preview before layout to avoid ANRs on very large saved searches. + binding.title.text = item.name.ellipsize(MAX_SAVED_SEARCH_PREVIEW_LENGTH) + binding.content.text = item.query.ellipsize(MAX_SAVED_SEARCH_PREVIEW_LENGTH) binding.root.setOnClickListener { viewModel.submitSavedSearch(item) } binding.insertSavedSearch.setOnClickListener { viewModel.applySavedSearch(item) } @@ -302,6 +304,8 @@ class StandardSearchFragment : companion object { const val TAG = "STANDARD" + + private const val MAX_SAVED_SEARCH_PREVIEW_LENGTH = 500 } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt index 64a2a7c96c21..4d87a66c39c7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt @@ -62,12 +62,12 @@ import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.requireActivity import com.ichi2.anki.snackbar.showSnackbar -import com.ichi2.anki.ui.NonLeadingZeroInputFilter import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.ext.dismissAllDialogFragments import com.ichi2.anki.utils.ext.getIntOrNull import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.withProgress +import com.ichi2.utils.NonLeadingZeroInputFilter import com.ichi2.utils.coMeasureTime import com.ichi2.utils.customView import com.ichi2.utils.moveCursorToEnd 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 4684cd0f6de8..23133ea096a0 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 @@ -106,7 +106,7 @@ class TagsDialog : AnalyticsDialogFragment { viewModelFactory { initializer { val idsFile = requireArguments().requireParcelable(ARG_TAGS_FILE) - val noteIds = idsFile.getIds() + val noteIds = idsFile.getIdsOrNull() ?: throw IOException("Tag selection is no longer available") val checkedTags = requireNotNull(requireArguments().getStringArrayList(ARG_CHECKED_TAGS)) { "$ARG_CHECKED_TAGS is required" @@ -151,7 +151,7 @@ class TagsDialog : AnalyticsDialogFragment { checkedTags: ArrayList = arrayListOf(), ): TagsDialog { // TODO: checkedTags is unbounded and could exceed the bundle size - val file = IdsFile(IdsFile.getDirectory(context), noteIds, IdsFile.Purpose.TAGS) + val file = IdsFile(IdsFile.getDirectory(), noteIds, IdsFile.Purpose.TAGS) arguments = this.arguments ?: Bundle().apply { putParcelable(ARG_TAGS_FILE, file) putParcelable(ARG_DIALOG_TYPE, type) @@ -389,8 +389,6 @@ class TagsDialog : AnalyticsDialogFragment { * @param prefixTag: The tag to be prefilled into the EditText section. A trailing '::' will be appended. */ @NeedsTest("The prefixTag should be prefilled properly") - @NeedsTest("Entering an existing tag should show an error and disable the OK button") - @NeedsTest("Entering a new valid tag should clear the error and enable the OK button") private fun createAddTagDialog(prefixTag: String?) { val addTagDialog = AlertDialog diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt index fba99888aa88..417432dfbfa2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt @@ -271,9 +271,12 @@ class ExportDialogFragment : AnalyticsDialogFragment() { buildExportLimit() } catch (e: IOException) { Timber.w(e, "Failed to read export IDs") - showThemedToast(requireContext(), CommonString.something_wrong, false) - return + null } + if (exportLimit == null) { + showThemedToast(requireContext(), CommonString.something_wrong, false) + return + } export(exportLimit) } @@ -345,18 +348,18 @@ class ExportDialogFragment : AnalyticsDialogFragment() { * the selected notes/cards or, if those are not present, to the selected * deck(or all decks). * - * @return an [ExportLimit] with the export constraints + * @return an [ExportLimit] with the export constraints, or null if the selection is unavailable */ - private fun buildExportLimit(): ExportLimit = - when (arguments?.getSerializableCompat(ARG_TYPE)) { + private fun buildExportLimit(): ExportLimit? { + return when (arguments?.getSerializableCompat(ARG_TYPE)) { ExportType.Notes -> { - val ids = requireArguments().requireParcelable(ARG_IDS_FILE).getIds() + val ids = requireArguments().requireParcelable(ARG_IDS_FILE).getIdsOrNull() ?: return null exportLimit { noteIds = noteIds { this.noteIds.addAll(ids) } } } ExportType.Cards -> { - val ids = requireArguments().requireParcelable(ARG_IDS_FILE).getIds() + val ids = requireArguments().requireParcelable(ARG_IDS_FILE).getIdsOrNull() ?: return null exportLimit { cardIds = cardIds { this.cids.addAll(ids) } } } @@ -372,6 +375,7 @@ class ExportDialogFragment : AnalyticsDialogFragment() { } } } + } /** Attempt to delete the associated [IdsFile] and logs the result */ private fun removeIdsFile() { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index 1ab9b041c851..f3a383d15c44 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -31,7 +31,7 @@ class ProfileManager private constructor( ) { private val profileContext = context.applicationContext ?: context - lateinit var activeProfileContext: Context + lateinit var activeProfileContext: ProfileContextWrapper private set /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt index 874b9e43b0bb..8068fea2fa13 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt @@ -51,6 +51,7 @@ import com.ichi2.utils.title import timber.log.Timber import java.util.Objects import kotlin.math.ceil +import com.ichi2.anki.common.android.R as CommonR /** * Handles the toolbar inside [com.ichi2.anki.NoteEditorFragment] @@ -189,7 +190,7 @@ class Toolbar : FrameLayout { ): AppCompatImageButton { // we use the light theme here to ensure the tint is black on both // A null theme can be passed after colorControlNormal is defined (API 25) - val themeContext: Context = ContextThemeWrapper(context, R.style.Theme_Light) + val themeContext: Context = ContextThemeWrapper(context, CommonR.style.Theme_Light) val d = VectorDrawableCompat.create(context.resources, drawable, themeContext.theme) return insertItem(id, d, block) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/ToolbarLayoutDialogHost.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/ToolbarLayoutDialogHost.kt index f5c84eb0de69..6f9df3a49651 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/ToolbarLayoutDialogHost.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/ToolbarLayoutDialogHost.kt @@ -3,21 +3,14 @@ package com.ichi2.anki.noteeditor import android.os.Bundle -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter import com.ichi2.anki.noteeditor.data.ToolbarPreferences -import com.ichi2.anki.noteeditor.presentation.ToolbarLayoutMode import com.ichi2.anki.settings.enums.DayTheme -import com.ichi2.anki.settings.enums.Theme import com.ichi2.compose.theme.AnkiDroidTheme -import com.ichi2.compose.ui.preview.AnkiDroidPreview -import com.ichi2.compose.ui.preview.AnkiDroidThemeProvider import com.ichi2.themes.Themes private const val STATE_TOOLBAR_LAYOUT_DIALOG = "toolbarLayoutDialog" @@ -64,65 +57,3 @@ internal class ToolbarLayoutDialogHost { outState.putBoolean(STATE_TOOLBAR_LAYOUT_DIALOG, isShowing) } } - -// TODO: Move these previews to ToolbarLayoutDialog.kt once theming is available in :common:android. -// https://github.com/ankidroid/Anki-Android/issues/22694 -@Preview(name = "Scroll") -@Composable -private fun ToolbarLayoutDialogPreview( - @PreviewParameter(AnkiDroidThemeProvider::class) theme: Theme, -) { - AnkiDroidPreview(theme = theme) { - ToolbarLayoutDialog( - selected = ToolbarLayoutMode.SCROLL, - onSelect = {}, - onDismiss = {}, - showSelectionBackground = theme != DayTheme.EINK, - ) - } -} - -@Preview(name = "Stack") -@Composable -private fun ToolbarLayoutDialogStackPreview( - @PreviewParameter(AnkiDroidThemeProvider::class) theme: Theme, -) { - AnkiDroidPreview(theme = theme) { - ToolbarLayoutDialog( - selected = ToolbarLayoutMode.STACK, - onSelect = {}, - onDismiss = {}, - showSelectionBackground = theme != DayTheme.EINK, - ) - } -} - -@Preview(name = "Hidden") -@Composable -private fun ToolbarLayoutDialogHiddenPreview( - @PreviewParameter(AnkiDroidThemeProvider::class) theme: Theme, -) { - AnkiDroidPreview(theme = theme) { - ToolbarLayoutDialog( - selected = ToolbarLayoutMode.HIDDEN, - onSelect = {}, - onDismiss = {}, - showSelectionBackground = theme != DayTheme.EINK, - ) - } -} - -@Preview(name = "Large text - short window", widthDp = 320, heightDp = 240, fontScale = 2f) -@Composable -private fun ToolbarLayoutDialogLargeTextPreview( - @PreviewParameter(AnkiDroidThemeProvider::class) theme: Theme, -) { - AnkiDroidPreview(theme = theme) { - ToolbarLayoutDialog( - selected = ToolbarLayoutMode.HIDDEN, - onSelect = {}, - onDismiss = {}, - showSelectionBackground = theme != DayTheme.EINK, - ) - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt index b43da2fc34bf..03e417bf34c6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt @@ -24,14 +24,15 @@ open class AnkiServer( Method.POST -> { val uri = session.uri Timber.d("POST: Requested %s", uri) - // NanoHTTPD closes the connection on body read failures by default + // NanoHTTPD responds and closes the connection if the body is rejected or can't be read val inputBytes = getSessionBytes(session) try { val data = runBlocking { postHandler.handlePostRequest(PostRequestUri(uri), inputBytes) } buildResponse(data) } catch (exception: Exception) { - Timber.w(exception, "buildResponse failure") - buildResponse(exception.localizedMessage?.encodeToByteArray(), status = Response.Status.INTERNAL_ERROR) + Timber.w(exception, "POST handler failed: %s", uri) + val message = exception.localizedMessage ?: exception.javaClass.name + buildResponse(message.encodeToByteArray(), status = Response.Status.INTERNAL_ERROR) } } Method.GET -> { @@ -39,21 +40,20 @@ open class AnkiServer( newFixedLengthResponse(Response.Status.NOT_FOUND, null, null) } else -> { - Timber.d("Ignored request of unhandled method %s, uri %s", session.method, session.uri) - newFixedLengthResponse(null) + Timber.d("Rejecting request of unhandled method %s, uri %s", session.method, session.uri) + // the request body is left unread, so the connection can't be reused + newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, null, null).apply { + addHeader("Allow", "POST") + closeConnection(true) + } } } private fun buildResponse( - data: ByteArray?, + data: ByteArray, mimeType: String = "application/binary", status: Response.IStatus = Response.Status.OK, - ): Response = - if (data == null) { - newFixedLengthResponse(null) - } else { - newChunkedResponse(status, mimeType, ByteArrayInputStream(data)) - } + ): Response = newFixedLengthResponse(status, mimeType, ByteArrayInputStream(data), data.size.toLong()) companion object { const val LOCALHOST = "127.0.0.1" @@ -63,8 +63,18 @@ open class AnkiServer( const val ANKIDROID_PREFIX = "/ankidroid/" const val ANKIDROID_JS_PREFIX = "/jsapi/" + /** + * @throws NanoHTTPD.ResponseException if the content-length is missing, or isn't + * ASCII digits within [Int]. NanoHTTPD sends its status and closes the connection + */ fun getSessionBytes(session: IHTTPSession): ByteArray { - val contentLength = session.headers["content-length"]!!.toInt() + val header = + session.headers["content-length"] + ?: throw ResponseException(Response.Status.LENGTH_REQUIRED, "content-length is required") + // RFC 9110: 1*DIGIT. toIntOrNull alone accepts a sign and non-ASCII digits + val contentLength = + header.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toIntOrNull() + ?: throw ResponseException(Response.Status.BAD_REQUEST, "invalid content-length: $header") val bytes = ByteArray(contentLength) DataInputStream(session.inputStream).readFully(bytes) return bytes diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/TimeboxSettingsScreen.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/TimeboxSettingsScreen.kt index 31a35d360ebf..d27f88803ad7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/TimeboxSettingsScreen.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/TimeboxSettingsScreen.kt @@ -296,7 +296,7 @@ private fun TimeboxDurationCard( enabled = (textValue.toIntOrNull() ?: minutes) > TimeboxSettingsViewModel.MIN_TIMEBOX_MINUTES, ) { Icon( - painter = painterResource(R.drawable.ic_remove), + painter = painterResource(CommonR.drawable.ic_remove), contentDescription = stringResource(CommonString.minus_sign), ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt index d5d3bdcb528d..2c312da0680f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt @@ -62,11 +62,11 @@ class PreviewerFragment : // Read before constructing the ViewModel, which immediately loads the first card. val ids = try { - arguments.requireParcelable(ARG_CARD_IDS_FILE).getIds() + arguments.requireParcelable(ARG_CARD_IDS_FILE).getIdsOrNull() } catch (e: IOException) { throw UnavailableSelectionException(e) } - if (ids.isEmpty()) throw UnavailableSelectionException() + if (ids.isNullOrEmpty()) throw UnavailableSelectionException() PreviewerViewModel(handle, ids, requireNotNull(arguments.getIntOrNull(ARG_CURRENT_INDEX))) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt index 3524785dd899..0780405ac327 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt @@ -8,16 +8,21 @@ import android.view.Window import android.view.WindowManager import androidx.activity.OnBackPressedCallback import androidx.activity.addCallback +import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope import androidx.lifecycle.repeatOnLifecycle import com.ichi2.anki.AnkiActivity import com.ichi2.anki.dialogs.LoadingDialogFragment -import com.ichi2.anki.dialogs.dismissLoadingDialog import com.ichi2.anki.dialogs.showLoadingDialog +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import timber.log.Timber import java.util.WeakHashMap @@ -33,9 +38,19 @@ fun AnkiActivity.observeProgress( viewModel: HasProgress, delayMillis: Duration = 600.milliseconds, resolveMessage: Context.(M) -> String, +) = observeProgressFor(this, viewModel, delayMillis, resolveMessage) + +private fun AnkiActivity.observeProgressFor( + owner: LifecycleOwner, + viewModel: HasProgress, + delayMillis: Duration, + resolveMessage: Context.(M) -> String, ) { val dialogTag = "${LoadingDialogFragment.TAG}:${viewModel.javaClass.name}" - var dialogVisible = supportFragmentManager.findFragmentByTag(dialogTag) != null + var holdingDialog = false + var reshowDialog: (() -> Unit)? = null + val dialogHolder: () -> Unit = { reshowDialog?.invoke() } + val ownerLifecycle = owner.lifecycle var backBlock: OnBackPressedCallback? = null fun blockInput() { @@ -51,54 +66,114 @@ fun AnkiActivity.observeProgress( window.unblockTouches() } - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - // Pending "show after delay" job survives subsequent Active emissions so the - // anti-flash delay isn't restarted on every progress update. Kept inside the - // block: a job cancelled on stop must not linger into the next start. - var pendingShow: Job? = null - viewModel.progressManager.progress.collect { state -> - when (state) { - is ViewModelProgress.Idle -> { - pendingShow?.cancel() - pendingShow = null - dialogVisible = false - dismissLoadingDialog(dialogTag) - unblockInput() - } - is ViewModelProgress.Active -> { - blockInput() - if (dialogVisible) { + fun holdDialog() { + if (holdingDialog) return + holdingDialog = true + supportFragmentManager.addDialogHolder(dialogTag, dialogHolder) + } + + fun releaseDialog() { + if (!holdingDialog) return + holdingDialog = false + supportFragmentManager.removeDialogHolder(dialogTag, dialogHolder) + } + + val restoringDialog = + !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && + supportFragmentManager.findFragmentByTag(dialogTag) != null + if (restoringDialog) holdDialog() + + val releaseIfOwnerStopped = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START && !ownerLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) releaseDialog() + } + lifecycle.addObserver(releaseIfOwnerStopped) + + ownerLifecycle.coroutineScope.launch { + try { + ownerLifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + // Pending "show after delay" job survives subsequent Active emissions so the + // anti-flash delay isn't restarted on every progress update. Kept inside the + // block: a job cancelled on stop must not linger into the next start. + var pendingShow: Job? = null + + fun showDialogAfter(wait: Duration) = + ownerLifecycle.coroutineScope.launch(Dispatchers.Main) { + delay(wait) + val latest = viewModel.progressManager.progress.value + if (latest is ViewModelProgress.Active) { + holdDialog() showLoadingDialog( - message = formatMessage(state, resolveMessage), - cancellable = state.cancellable, + message = formatMessage(latest, resolveMessage), + cancellable = latest.cancellable, tag = dialogTag, ) - if (state.cancellable) wireCancelListener(viewModel, dialogTag) - } else if (pendingShow == null) { - pendingShow = - launch { - delay(delayMillis) - val latest = viewModel.progressManager.progress.value - if (latest is ViewModelProgress.Active) { - dialogVisible = true - showLoadingDialog( - message = formatMessage(latest, resolveMessage), - cancellable = latest.cancellable, - tag = dialogTag, - ) - if (latest.cancellable) wireCancelListener(viewModel, dialogTag) - } - pendingShow = null - } + if (latest.cancellable) wireCancelListener(viewModel, dialogTag) + } + pendingShow = null + } + + reshowDialog = { + pendingShow?.cancel() + pendingShow = showDialogAfter(Duration.ZERO) + } + val progress = + viewModel.progressManager.progress.onCompletion { + reshowDialog = null + pendingShow?.cancel() + unblockInput() + if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) releaseDialog() + } + progress.collect { state -> + when (state) { + is ViewModelProgress.Idle -> { + pendingShow?.cancel() + pendingShow = null + releaseDialog() + unblockInput() + } + is ViewModelProgress.Active -> { + blockInput() + if (holdingDialog) { + pendingShow?.cancel() + pendingShow = showDialogAfter(Duration.ZERO) + } else if (pendingShow == null) { + pendingShow = showDialogAfter(delayMillis) + } } } } } + } finally { + lifecycle.removeObserver(releaseIfOwnerStopped) + releaseDialog() } } } +private val dialogHolders = WeakHashMap Unit>>>() + +private fun FragmentManager.addDialogHolder( + tag: String, + reshow: () -> Unit, +) { + dialogHolders.getOrPut(this) { mutableMapOf() }.getOrPut(tag) { mutableListOf() }.add(reshow) +} + +private fun FragmentManager.removeDialogHolder( + tag: String, + reshow: () -> Unit, +) { + val holders = dialogHolders[this]?.get(tag) ?: return + holders.remove(reshow) + if (holders.isNotEmpty()) { + holders.last().invoke() + return + } + dialogHolders[this]?.remove(tag) + (findFragmentByTag(tag) as? DialogFragment)?.dismissAllowingStateLoss() +} + private val touchBlockCounts = WeakHashMap() internal fun Window.blockTouches() { @@ -131,7 +206,7 @@ private fun AnkiActivity.wireCancelListener( } /** - * Fragment wrapper that delegates to the host activity. + * Follows this fragment's lifecycle, showing the dialog and blocking touches and back on the host activity. * * If the host is not an [AnkiActivity], logs a warning and returns — no observer is set up. * @@ -154,7 +229,7 @@ fun Fragment.observeProgress( ) return } - activity.observeProgress(viewModel, delayMillis, resolveMessage) + activity.observeProgressFor(this, viewModel, delayMillis, resolveMessage) } private fun Context.formatMessage( 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 a8c71d294dc2..d69ccafd584d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt @@ -93,8 +93,8 @@ class SetDueDateDialog : AnalyticsDialogFragment() { // used to determine if a rotation has taken place private var initialRotation: Int = 0 - val cardIds: List - get() = requireArguments().requireParcelable(ARG_IDS_FILE).getIds() + val cardIds: List? + get() = requireArguments().requireParcelable(ARG_IDS_FILE).getIdsOrNull() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -102,12 +102,14 @@ class SetDueDateDialog : AnalyticsDialogFragment() { try { this.cardIds } catch (e: IOException) { - // the file may be missing, or truncated if the write was interrupted Timber.w(e, "Failed to read cardIds") - showThemedToast(requireContext(), CommonString.something_wrong, false) - dismiss() - return + null } + if (cardIds == null) { + showThemedToast(requireContext(), CommonString.something_wrong, false) + dismiss() + return + } viewModel.init(cardIds) Timber.d("Set due date dialog: %d card(s)", cardIds.size) @@ -278,7 +280,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() { Timber.d("Ignoring 'set due date' request: dialog is already open") return } - val dialog = newInstance(IdsFile.getDirectory(activity), cardIds) + val dialog = newInstance(IdsFile.getDirectory(), cardIds) activity.showDialogFragment(dialog) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/DayTheme.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/DayTheme.kt deleted file mode 100644 index 121559fd80d2..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/DayTheme.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package com.ichi2.anki.settings.enums - -import androidx.annotation.StyleRes -import com.ichi2.anki.R -import com.ichi2.anki.common.android.R as CommonR - -sealed interface Theme : PrefEnum { - @get:StyleRes - val styleResId: Int -} - -/** [CommonR.array.day_theme_values] */ -enum class DayTheme( - override val entryResId: Int, - override val styleResId: Int, -) : Theme { - LIGHT(CommonR.string.theme_light_value, R.style.Theme_Light), - PLAIN(CommonR.string.theme_plain_value, R.style.Theme_Light_Plain), - EINK(CommonR.string.theme_eink_scheme_value, R.style.Theme_Light_Eink), -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/NightTheme.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/NightTheme.kt deleted file mode 100644 index 42493d7c59a3..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/NightTheme.kt +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package com.ichi2.anki.settings.enums - -import com.ichi2.anki.R -import com.ichi2.anki.common.android.R as CommonR - -/** [CommonR.array.night_theme_values] */ -enum class NightTheme( - override val entryResId: Int, - override val styleResId: Int, -) : Theme { - BLACK(CommonR.string.theme_black_value, R.style.Theme_Dark_Black), - DARK(CommonR.string.theme_dark_value, R.style.Theme_Dark), -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt index 74762b9f77e5..6bfa07aacda4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt @@ -11,6 +11,7 @@ import android.view.MenuItem import android.view.ViewGroup.MarginLayoutParams import android.webkit.CookieManager import android.webkit.URLUtil +import android.webkit.WebBackForwardList import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse @@ -58,13 +59,22 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { private var shouldHistoryBeCleared = false + private var isReturningAfterLogin = false + + private val pagesBeforeLogin = mutableListOf() + private var loginRequiredDialog: AlertDialog? = null private val allowedHosts = listOf(Regex("""^(?:.*\.)?ankiweb\.net$"""), Regex("""^ankiuser\.net$"""), Regex("""^ankisrs\.net$""")) private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (binding.webView.canGoBack()) binding.webView.goBack() + if (isReturningAfterLogin) return + if (binding.webView.canGoBack()) { + binding.webView.goBack() + } else { + pagesBeforeLogin.removeLastOrNull()?.let { returnTo(it) } + } } } @@ -83,7 +93,14 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { isReload: Boolean, ) { super.doUpdateVisitedHistory(view, url, isReload) - onBackPressedCallback.isEnabled = binding.webView.canGoBack() + if (isReturningAfterLogin && url?.startsWith(SHARED_PAGES_PREFIX) == true) { + binding.webView.clearHistory() + isReturningAfterLogin = false + } + updateBackPressedCallback() + if (url?.startsWith(AFTER_LOGIN_URL) == true && isLoggedInToAnkiWeb()) { + returnToSharedDecks() + } } override fun onPageFinished( @@ -94,6 +111,7 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { if (shouldHistoryBeCleared) { binding.webView.clearHistory() shouldHistoryBeCleared = false + updateBackPressedCallback() } super.onPageFinished(view, url) } @@ -167,8 +185,6 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { private fun redirectToLogin() { val url = getString(R.string.shared_decks_login_url) Timber.i("HTTP 429, redirecting to login: '$url'") - // TODO: the result of login is typically redirecting the user to their decks - // this should be improved binding.webView.loadUrl(url) } @@ -178,6 +194,28 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { binding.webView.loadUrl(url) } + private fun returnToSharedDecks() { + if (isReturningAfterLogin) return + val history = binding.webView.copyBackForwardList() + val sharedPages = + history.urlsBeforeCurrent + .filter { it.startsWith(SHARED_PAGES_PREFIX) } + .toList() + val url = sharedPages.lastOrNull() ?: return + Timber.d("Logged in to AnkiWeb, returning to '$url'") + pagesBeforeLogin += sharedPages.dropLast(1) + returnTo(url) + } + + private fun returnTo(url: String) { + isReturningAfterLogin = true + binding.webView.loadUrl(url) + } + + private fun updateBackPressedCallback() { + onBackPressedCallback.isEnabled = binding.webView.canGoBack() || pagesBeforeLogin.isNotEmpty() + } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val webViewClient = SharedDeckWebViewClient() @@ -186,6 +224,9 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { const val ARG_DOWNLOAD_FILE = "DownloadFile" const val HTTP_STATUS_TOO_MANY_REQUESTS = 429 + + private const val AFTER_LOGIN_URL = "https://ankiweb.net/account/login?afterAuth=1" + private const val SHARED_PAGES_PREFIX = "https://ankiweb.net/shared/" } // Show WebView with AnkiWeb shared decks with the functionality to capture downloads and import decks. @@ -280,6 +321,7 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { if (item.itemId == R.id.home) { // R.id.home refers to a custom 'home' menu item defined in your app resources (res/menu/...). shouldHistoryBeCleared = true + pagesBeforeLogin.clear() binding.webView.loadUrl(resources.getString(R.string.shared_decks_url)) } else if (item.itemId == android.R.id.home) { // android.R.id.home refers to the system-provided "up" button in the app toolbar @@ -341,3 +383,6 @@ internal fun isLoggedInToAnkiWeb(): Boolean = Timber.w(e, "Could not determine login status") false } + +private val WebBackForwardList.urlsBeforeCurrent: Sequence + get() = (0 until currentIndex).asSequence().map { getItemAtIndex(it).url } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 79a513faf39c..9b2e41083791 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -54,7 +54,11 @@ class ManageTagsViewModel : ViewModel() { /** Cached flat list of all tags, rebuilt when the backend tree changes */ private var tagList: List = emptyList() + /** Explicit collapses override search expansion until the query changes. */ + private val searchCollapsedTags = mutableSetOf() + private var tagOperation: Job? = null + private var expansionSave: Job? = null init { refreshTags() @@ -73,6 +77,7 @@ class ManageTagsViewModel : ViewModel() { * of having a filtered tag selected. */ fun filter(query: String) { + if (query != searchQuery.value) searchCollapsedTags.clear() searchQuery.value = query updateState { loaded -> loaded.copy( @@ -87,44 +92,62 @@ class ManageTagsViewModel : ViewModel() { /** Checks the complete tag tree, including tags hidden by search or collapsed ancestors. */ fun tagExists(tag: TagName): Boolean = tagList.any { it.fullTag == tag } - /** Returns the visible node for [tag], or null with a warning if not found or not loaded */ - private fun findVisibleNode(tag: TagName): TagListItemState? { + /** Finds a tag using its displayed expansion state when visible. */ + private fun findTagNode(tag: TagName): TagListItemState? { val loaded = state.value as? ManageTagsState.Content if (loaded == null) { - Timber.w("findVisibleNode called while not loaded") + Timber.w("findTagNode called while not loaded") return null } - return loaded.visibleNodes.firstOrNull { it.fullTag == tag }.also { + return (loaded.visibleNodes.firstOrNull { it.fullTag == tag } ?: tagList.firstOrNull { it.fullTag == tag }).also { if (it == null) { - Timber.w("Tag not found in visible nodes") + Timber.w("Tag not found in hierarchy") Timber.d("Tag: %s", tag) } } } /** - * Expands or collapses [tag] in the tree. Persists the state to the backend. + * Expands or collapses [tag] immediately, then saves the preference without a busy state. + * Saves run in tap order; a save failure leaves the visible hierarchy intact and reports the error. * * PERF: ~55ms to process 10k+ tags on my M1; ~350ms to process 10k+ tags in CI * * @see Tags.setCollapsed */ - fun toggleCollapsed(tag: TagName) = - launchTagOperation { - val node = - findVisibleNode(tag) ?: run { - messageQueue.enqueue(UserMessage.UnexpectedError) - return@launchTagOperation + fun toggleCollapsed(tag: TagName): Job { + tagOperation?.takeUnless { it.isCompleted }?.let { return it } + val previousSave = expansionSave + val operation = + viewModelScope.launch(start = CoroutineStart.LAZY) { + val node = + findTagNode(tag) ?: run { + messageQueue.enqueue(UserMessage.UnexpectedError) + return@launch + } + val newCollapsed = !node.collapsed + if (searchQuery.value.isNotBlank()) { + if (newCollapsed) searchCollapsedTags.add(node.fullTag) else searchCollapsedTags.remove(node.fullTag) } - val newCollapsed = !node.collapsed - withCol { tags.setCollapsed(node.fullTag, newCollapsed) } - // Update the in-memory list with the new collapsed state - tagList = - tagList.map { - if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it + tagList = + tagList.map { + if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it + } + updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value), error = null) } + previousSave?.join() + try { + withCol { tags.setCollapsed(node.fullTag, newCollapsed) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Saving tag expansion failed") + updateState { it.copy(error = e) } } - updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value)) } - } + } + expansionSave = operation + operation.start() + return operation + } /** * Deletes [tag] and its children from all notes. @@ -170,7 +193,14 @@ class ManageTagsViewModel : ViewModel() { ) = launchTagOpAndRefresh { Timber.i("Moving tag") val result = undoableOp { moveTagTree(tag, newParent) } - messageQueue.enqueue(TagMoved(result.count)) + val currentParent = TagName.build(tag.value.substringBeforeLast("::", "")) + messageQueue.enqueue( + if (result.count == 0 && currentParent != newParent) { + UserMessage.UnusedTagNotMoved + } else { + TagMoved(result.count) + }, + ) } private suspend fun loadTags() { @@ -200,7 +230,14 @@ class ManageTagsViewModel : ViewModel() { } // set 'tagOperation' before executing - val operation = viewModelScope.launch(start = CoroutineStart.LAZY) { runTagOperation(block) } + val operation = + viewModelScope.launch(start = CoroutineStart.LAZY) { + runTagOperation { + // Finish preference saves before a reload or a mutation can rename/remove their tags. + expansionSave?.join() + block() + } + } tagOperation = operation // calls from observers will now find this job operation.start() @@ -241,7 +278,7 @@ class ManageTagsViewModel : ViewModel() { applyCollapsedVisibility(tagList) } else { // collapsed nodes can be re-expanded if searchQuery matches the leaf nodes - applySearchFilter(tagList, searchQuery) + applySearchFilter(tagList, searchQuery, searchCollapsedTags) } /** Applies [transform] only if the current state is [ManageTagsState.Content]; no-ops otherwise */ @@ -272,7 +309,7 @@ class ManageTagsViewModel : ViewModel() { val fullTag = if (parentFullTag.isEmpty()) child.name else "$parentFullTag::${child.name}" result.add( TagListItemState( - fullTag = TagName.build(fullTag) ?: error("Invalid tag provided"), + fullTagName = fullTag, displayName = child.name, level = child.level - 1, hasChildren = child.childrenList.isNotEmpty(), @@ -309,7 +346,9 @@ class ManageTagsViewModel : ViewModel() { /** * Filters a flat tag list to items matching [searchQuery] (case-insensitive substring), * plus their ancestors to preserve hierarchy. - * Collapsed nodes are expanded if their children match, ensuring leaf matches are visible. + * Expands ancestors only when a match reaches a child's own name. A match inherited + * from a parent's name preserves the subtree's existing collapsed state. + * [manuallyCollapsedTags] prevents search from reopening tags collapsed by the user. * * O(n*d) where n = number of tags and d = maximum tag depth. */ @@ -317,52 +356,64 @@ class ManageTagsViewModel : ViewModel() { fun applySearchFilter( allNodes: List, searchQuery: String, + manuallyCollapsedTags: Set = emptySet(), ): List { val queryLowercase = searchQuery.lowercase() val visibleTags = mutableSetOf() - val parentsWithVisibleChildren = mutableSetOf() + val ancestorsToExpand = mutableSetOf() for (item in allNodes) { if (item.containsLowercase(queryLowercase)) { visibleTags.add(item.fullTag.value) + val expandAncestors = item.matchesOwnComponent(queryLowercase) for (ancestor in item.fullTag.ancestors) { visibleTags.add(ancestor) - parentsWithVisibleChildren.add(ancestor) + if (expandAncestors) ancestorsToExpand.add(ancestor) } } } - return allNodes - .filter { it.fullTag.value in visibleTags } - .map { - // expand a collapsed parent if a child matches the search - if (it.collapsed && it.fullTag.value in parentsWithVisibleChildren) { - it.copy(collapsed = false) - } else { - it + val filteredNodes = + allNodes + .filter { it.fullTag.value in visibleTags } + .map { + if (it.collapsed && it.fullTag.value in ancestorsToExpand && it.fullTag !in manuallyCollapsedTags) { + it.copy(collapsed = false) + } else { + it + } } - } + return applyCollapsedVisibility(filteredNodes) } } } /** - * A flattened representation of a tag for display in a RecyclerView. + * A flattened tag row. Equality includes the full spelling so UI updates reach Compose. * - * @param fullTag full hierarchical tag path, e.g. "science::biology" + * @param fullTagName full hierarchical tag path, e.g. "science::biology" * @param displayName leaf name only, e.g. "biology" * @param level tree depth (0 = top-level) */ data class TagListItemState( - val fullTag: TagName, + val fullTagName: String, val displayName: String, val level: Int, val hasChildren: Boolean, val collapsed: Boolean, ) { + val fullTag: TagName = requireNotNull(TagName.build(fullTagName)) + // cache the tag name to support fast case-insensitive searches - private val fullTagLower: String = fullTag.value.lowercase() + private val fullTagLower: String = fullTagName.lowercase() /** Case-insensitive check against the full tag path. [query] must be pre-lowercased. */ fun containsLowercase(query: String): Boolean = fullTagLower.contains(query) + + /** Whether pre-lowercased [query] reaches this tag's own name or its preceding separator. */ + fun matchesOwnComponent(query: String): Boolean { + val matchStart = fullTagLower.lastIndexOf(query) + // Include the separator so a query such as "parent::" reveals its immediate children. + return matchStart >= 0 && matchStart + query.length > fullTagLower.lastIndexOf("::") + } } sealed class ManageTagsState { @@ -401,6 +452,12 @@ sealed interface UserMessage { val notesAffected: Int, ) : UserMessage + /** + * The backend leaves an entirely unused subtree in place when asked to move it. + * Remove this message and its handling when [Anki issue 5870](https://github.com/ankitects/anki/issues/5870) is fixed. + */ + data object UnusedTagNotMoved : UserMessage + /** @param count number of unused tags removed from the collection */ data class ClearedUnusedTags( val count: Int, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt index 49c72c78bc03..920c487fb0a4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt @@ -97,6 +97,12 @@ class TagName private constructor( } } +/** Returns this tag's path under [parent], or at the top level when the parent is null. */ +internal fun TagName.withParent(parent: TagName?): TagName { + val leaf = value.substringAfterLast("::") + return requireNotNull(TagName.build(parent?.let { "${it.value}::$leaf" } ?: leaf)) +} + // Extension methods serve two purposes: removing `.value` from the call site // and ensuring that collection operation lambdas return `OpChanges` // Having Timber.d as a final call means the Lambda returns `Unit`, which is converted to `Any` diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt index 51158409f324..bd24df20a8e8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt @@ -40,8 +40,7 @@ internal fun Collection.moveTagTree( newParent: TagName?, ): OpChangesWithCount { require(newParent == null || !newParent.isSameOrDescendantOf(tag)) { "A tag cannot be moved under itself" } - val leaf = tag.value.substringAfterLast("::") - val destination = TagName.build(newParent?.let { "${it.value}::$leaf" } ?: leaf)!! + val destination = tag.withParent(newParent) if (destination == tag) return opChangesWithCount { } // Moving one subtree is a prefix rename. The backend reparent operation also rejects // valid sibling prefixes (e.g. parent::a beneath parent::ab). diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/ui/dialogs/TextInputDialog.kt b/AnkiDroid/src/main/java/com/ichi2/compose/ui/dialogs/TextInputDialog.kt index 66ad37575c75..ef008a2c9819 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compose/ui/dialogs/TextInputDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compose/ui/dialogs/TextInputDialog.kt @@ -5,13 +5,16 @@ package com.ichi2.compose.ui.dialogs import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -29,9 +32,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ichi2.anki.CommonString import com.ichi2.compose.ui.preview.AnkiDroidPreview @@ -42,7 +50,14 @@ import com.ichi2.compose.ui.preview.DialogPreviews * the View based `AlertDialog.input(...)` helper. * * @param onConfirm called with the text, but only while it is valid + * @param initialText initial value shown in the field + * @param initialSelection initial cursor position or selected range; defaults to the end of [initialText] * @param errorMessageFor error to show for the current input, null when valid + * @param helperText shown when no error is displayed; an empty string reserves feedback space + * @param description optional context below the title + * @param transformInput applied to text edits, not cursor-only changes + * @param validateOnInput when false, validate on confirmation instead of while editing + * @param enabled whether confirmation is allowed from the button or keyboard */ @Composable fun TextInputDialog( @@ -53,137 +68,124 @@ fun TextInputDialog( onDismissRequest: () -> Unit, dismissText: String = stringResource(CommonString.dialog_cancel), initialText: String = "", + initialSelection: TextRange = TextRange(initialText.length), maxLengthCounter: Int? = null, lengthOf: (String) -> Int = { it.length }, capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, errorMessageFor: (String) -> String? = { null }, + helperText: String? = null, + description: String? = null, + transformInput: (TextFieldValue) -> TextFieldValue = { it }, + validateOnInput: Boolean = true, + enabled: Boolean = true, ) { - AnkiDroidDialog(onDismissRequest = onDismissRequest) { - TextInputDialogCard( - title = title, - label = label, - confirmText = confirmText, - onConfirm = onConfirm, - onDismissRequest = onDismissRequest, - dismissText = dismissText, - initialText = initialText, - maxLengthCounter = maxLengthCounter, - lengthOf = lengthOf, - capitalization = capitalization, - errorMessageFor = errorMessageFor, - ) + var input by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(initialText, selection = initialSelection)) } -} - -/** The dialog card itself. Metrics match Material3's own AlertDialog. */ -@Composable -private fun TextInputDialogCard( - title: String, - label: String, - confirmText: String, - onConfirm: (String) -> Unit, - onDismissRequest: () -> Unit, - dismissText: String, - initialText: String, - maxLengthCounter: Int?, - lengthOf: (String) -> Int, - capitalization: KeyboardCapitalization, - errorMessageFor: (String) -> String?, -) { - var text by rememberSaveable { mutableStateOf(initialText) } var hasUserEdited by rememberSaveable { mutableStateOf(false) } - val errorMessage = errorMessageFor(text) - val displayedError = errorMessage?.takeIf { hasUserEdited } + var submissionAttempted by rememberSaveable { mutableStateOf(false) } + val errorMessage = if (validateOnInput || submissionAttempted) errorMessageFor(input.text) else null + val displayedError = errorMessage?.takeIf { hasUserEdited || submissionAttempted } + val supportingMessage = displayedError ?: helperText + val hasSupportingText = maxLengthCounter != null || supportingMessage != null || !validateOnInput + val focusRequester = remember { FocusRequester() } fun confirmIfValid() { - if (errorMessageFor(text) == null) onConfirm(text) + if (!enabled) return + submissionAttempted = true + if (errorMessageFor(input.text) == null) onConfirm(input.text) } - - val focusRequester = remember { FocusRequester() } - - Surface( - modifier = Modifier.widthIn(min = 280.dp, max = 560.dp), - shape = AlertDialogDefaults.shape, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column(modifier = Modifier.padding(24.dp)) { - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - color = AlertDialogDefaults.titleContentColor, - modifier = Modifier.padding(bottom = 16.dp), - ) - OutlinedTextField( - value = text, - onValueChange = { - text = it - hasUserEdited = true - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - label = { Text(label) }, - isError = displayedError != null, - supportingText = - if (displayedError == null && maxLengthCounter == null) { - null - } else { - { - Row(modifier = Modifier.fillMaxWidth()) { - if (displayedError != null) { - Text(displayedError, modifier = Modifier.weight(1f)) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - if (maxLengthCounter != null) { - Text("${lengthOf(text)}/$maxLengthCounter") - } + AnkiDroidDialog(onDismissRequest = onDismissRequest) { + Surface( + modifier = Modifier.widthIn(min = 280.dp, max = 560.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Layout(modifier = Modifier.padding(24.dp), content = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text( + title, + style = MaterialTheme.typography.headlineSmall, + color = AlertDialogDefaults.titleContentColor, + modifier = Modifier.padding(bottom = if (description == null) 16.dp else 8.dp), + ) + if (description != null) { + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = AlertDialogDefaults.textContentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + OutlinedTextField( + value = input, + onValueChange = { proposed -> + if (input.text != proposed.text) { + input = transformInput(proposed) + hasUserEdited = true + submissionAttempted = false + } else { + input = proposed } - } - }, - singleLine = true, - keyboardOptions = - KeyboardOptions( - capitalization = capitalization, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { confirmIfValid() }), - ) - LaunchedEffect(focusRequester) { focusRequester.requestFocus() } - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 24.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - TextButton(onClick = onDismissRequest) { - Text(dismissText) + }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + label = { Text(label, Modifier.testTag("text_input_label")) }, + isError = displayedError != null, + supportingText = + if (!hasSupportingText) { + null + } else { + { + Row(Modifier.fillMaxWidth()) { + if (supportingMessage != null || !validateOnInput) { + Text(supportingMessage.orEmpty(), Modifier.weight(1f)) + } else { + Spacer(Modifier.weight(1f)) + } + if (maxLengthCounter != null) Text("${lengthOf(input.text)}/$maxLengthCounter") + } + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = capitalization, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { confirmIfValid() }), + ) } - TextButton( - onClick = ::confirmIfValid, - enabled = errorMessage == null, + FlowRow( + modifier = Modifier.fillMaxWidth().padding(top = if (hasSupportingText) 8.dp else 24.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text(confirmText) + TextButton(onClick = onDismissRequest) { Text(dismissText) } + TextButton( + onClick = ::confirmIfValid, + enabled = enabled && (!validateOnInput || errorMessage == null), + ) { Text(confirmText) } + } + }) { children, constraints -> + val actions = children[1].measure(constraints.copy(minHeight = 0)) + val body = + children[0].measure( + constraints.copy(minHeight = 0, maxHeight = (constraints.maxHeight - actions.height).coerceAtLeast(0)), + ) + layout(constraints.maxWidth, body.height + actions.height) { + body.placeRelative(0, 0) + actions.placeRelative(0, body.height) } } + LaunchedEffect(focusRequester) { focusRequester.requestFocus() } } } } +/** @see TextInputDialog */ @DialogPreviews @Composable private fun TextInputDialogPreview() { AnkiDroidPreview { - TextInputDialog( - title = "Rename deck", - label = "Deck name", - confirmText = "Rename", - onConfirm = {}, - onDismissRequest = {}, - maxLengthCounter = 50, - ) + TextInputDialog("Rename deck", "Deck name", "Rename", {}, {}, maxLengthCounter = 50) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/IncrementerNumberRangePreferenceCompat.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/IncrementerNumberRangePreferenceCompat.kt index 12b830c1bedf..46edc9c504c1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/IncrementerNumberRangePreferenceCompat.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/IncrementerNumberRangePreferenceCompat.kt @@ -12,7 +12,7 @@ import androidx.core.widget.doAfterTextChanged import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString import com.ichi2.anki.databinding.DialogIncrementerPreferenceBinding -import com.ichi2.anki.ui.NonLeadingZeroInputFilter +import com.ichi2.utils.NonLeadingZeroInputFilter import com.ichi2.utils.moveCursorToEnd import com.ichi2.utils.positiveButton diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/NumberRangePreferenceCompat.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/NumberRangePreferenceCompat.kt index ea172d1a4753..c5a39468aefd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/NumberRangePreferenceCompat.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/NumberRangePreferenceCompat.kt @@ -7,20 +7,15 @@ import android.content.Context import android.text.InputFilter import android.text.InputType import android.util.AttributeSet -import android.util.TypedValue import android.view.View import android.widget.EditText import androidx.core.content.withStyledAttributes -import androidx.core.widget.TextViewCompat import androidx.preference.EditTextPreference import androidx.preference.EditTextPreferenceDialogFragmentCompat -import com.google.android.material.textview.MaterialTextView import com.ichi2.anki.R -import com.ichi2.anki.common.utils.dp import com.ichi2.anki.utils.getFormattedStringOrPlurals -import com.ichi2.utils.setPaddingRelative +import com.ichi2.utils.numberRangeTitle import timber.log.Timber -import com.google.android.material.R as MaterialR open class NumberRangePreferenceCompat @JvmOverloads // fixes: Error inflating class com.ichi2.preferences.NumberRangePreferenceCompat @@ -165,30 +160,7 @@ open class NumberRangePreferenceCompat override fun onPrepareDialogBuilder(builder: androidx.appcompat.app.AlertDialog.Builder) { super.onPrepareDialogBuilder(builder) - val titleText = preference.dialogTitle ?: preference.title - if (titleText.isNullOrEmpty()) { - return - } - // Use a custom MaterialTextView to match the title text appearance and padding, - // as seen in other dialogs (e.g. "Create deck"). The default TextView renders - // the title too small and inconsistent with Material dialog standards. - val tv = - MaterialTextView(requireContext()).apply { - text = titleText - setPaddingRelative(start = 24.dp, top = 20.dp, end = 24.dp, bottom = 8.dp) - - val outValue = TypedValue() - val hasStyle = - context.theme.resolveAttribute(MaterialR.attr.materialAlertDialogTitleTextStyle, outValue, true) - val styleRes = if (hasStyle) outValue.resourceId else 0 - if (styleRes != 0) { - TextViewCompat.setTextAppearance(this, styleRes) - } else { - TextViewCompat.setTextAppearance(this, MaterialR.style.TextAppearance_Material3_HeadlineSmall) - } - } - - builder.setCustomTitle(tv) + builder.numberRangeTitle(preference.dialogTitle ?: preference.title, requireContext()) } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/FileUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/FileUtil.kt index b846b19479fe..0c9b84df9cc1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/FileUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/FileUtil.kt @@ -111,6 +111,18 @@ object FileUtil { fun listFiles(dir: File): Array = dir.listFiles() ?: throw IOException("Failed to list the contents of '$dir'") + + /** + * Returns [dir]'s children, or an empty array when [File.listFiles] returns `null` + * or throws [SecurityException]. Security exceptions are logged. + */ + fun listFilesOrEmpty(dir: File): Array = + try { + dir.listFiles().orEmpty() + } catch (e: SecurityException) { + Timber.w(e, "Unable to list directory: %s", dir) + emptyArray() + } } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ViewUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ViewUtils.kt index 6c88483685f9..70d09f5dc4ec 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ViewUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ViewUtils.kt @@ -7,7 +7,6 @@ import android.content.res.Resources import android.view.View import androidx.core.view.OnReceiveContentListener import androidx.draganddrop.DropHelper -import com.ichi2.anki.common.utils.Dp /** @see View.performClick */ fun View.performClickIfEnabled() { @@ -35,64 +34,6 @@ fun View.configureView( ) } -/** - * Sets the relative padding for all dimensions of the view - * - * The view may add on the space required to display the scrollbars, - * depending on the style and visibility of the scrollbars. - * So the values returned from `getPadding` calls - * may be different from the values set in this call. - */ -fun View.setPaddingRelative(px: Int) = setPaddingRelative(px, px, px, px) - -/** - * Sets the relative padding for all dimensions of the view - * - * The view may add on the space required to display the scrollbars, - * depending on the style and visibility of the scrollbars. - * So the values returned from `getPadding` calls - * may be different from the values set in this call. - */ -@Suppress("unused") -fun View.setPaddingRelative(dp: Dp) = setPaddingRelative(dp.toPx(context)) - -/** - * Sets the relative padding - * - * The view may add on the space required to display the scrollbars, - * depending on the style and visibility of the scrollbars. - * So the values returned from `getPadding` calls - * may be different from the values set in this call. - */ -// Since we're in Kotlin, this now allows named arguments! -@Suppress("unused") -fun View.setPaddingRelative( - start: Dp, - top: Dp, - end: Dp, - bottom: Dp, -) = setPaddingRelative(start.toPx(context), top.toPx(context), end.toPx(context), bottom.toPx(context)) - -/** - * Updates the relative padding for the [View] - * This version of the method allows using named parameters to just set one or more axes. - * - * @see View.setPaddingRelative - */ -fun View.updatePaddingRelative( - start: Dp? = null, - top: Dp? = null, - end: Dp? = null, - bottom: Dp? = null, -) { - setPaddingRelative( - start?.toPx(context) ?: this.paddingStart, - top?.toPx(context) ?: this.paddingTop, - end?.toPx(context) ?: this.paddingEnd, - bottom?.toPx(context) ?: this.paddingBottom, - ) -} - fun View.isRtl() = isRtl(resources) fun isRtl(res: Resources): Boolean = res.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL diff --git a/AnkiDroid/src/main/res/layout/view_browser_column_cell.xml b/AnkiDroid/src/main/res/layout/view_browser_column_cell.xml index 05478ca1353b..00b66502f784 100644 --- a/AnkiDroid/src/main/res/layout/view_browser_column_cell.xml +++ b/AnkiDroid/src/main/res/layout/view_browser_column_cell.xml @@ -4,8 +4,9 @@ android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" + android:breakStrategy="simple" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingVertical="1dp" android:layout_gravity="top" -/> \ No newline at end of file +/> diff --git a/AnkiDroid/src/main/res/layout/view_saved_search_item.xml b/AnkiDroid/src/main/res/layout/view_saved_search_item.xml index 0fa947b3920b..c7a1e0f5713c 100644 --- a/AnkiDroid/src/main/res/layout/view_saved_search_item.xml +++ b/AnkiDroid/src/main/res/layout/view_saved_search_item.xml @@ -30,6 +30,8 @@ android:orientation="vertical" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="2" + android:ellipsize="end" android:gravity="start|center_vertical" android:textAlignment="viewStart" android:textSize="18sp" @@ -41,6 +43,8 @@ android:orientation="vertical" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="3" + android:ellipsize="end" android:gravity="start|center_vertical" android:textAlignment="viewStart" android:drawablePadding="10dp" @@ -64,4 +68,4 @@ - \ No newline at end of file + diff --git a/AnkiDroid/src/main/res/values/theme_black.xml b/AnkiDroid/src/main/res/values/theme_black.xml deleted file mode 100644 index 8ac13482e7f3..000000000000 --- a/AnkiDroid/src/main/res/values/theme_black.xml +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - + diff --git a/common/android/src/main/res/values/theme_black.xml b/common/android/src/main/res/values/theme_black.xml index e78ca81fcbc7..545a43b2f0e5 100644 --- a/common/android/src/main/res/values/theme_black.xml +++ b/common/android/src/main/res/values/theme_black.xml @@ -6,4 +6,146 @@ #151515 #fff #1a1a1a + + + + + + + + + + + + + + + +