diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 823542920d72..d27c6b4087f9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -65,6 +65,7 @@ import androidx.core.view.isVisible import androidx.draganddrop.DropHelper import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commit +import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager @@ -129,6 +130,7 @@ import com.ichi2.anki.dialogs.FatalErrorDialog import com.ichi2.anki.dialogs.ImportDialog.ImportDialogListener import com.ichi2.anki.dialogs.ImportFileSelectionFragment.ApkgImportResultLauncherProvider import com.ichi2.anki.dialogs.ImportFileSelectionFragment.CsvImportResultLauncherProvider +import com.ichi2.anki.dialogs.ImportViewModel import com.ichi2.anki.dialogs.SchedulerUpgradeDialog import com.ichi2.anki.dialogs.SyncErrorDialog import com.ichi2.anki.dialogs.SyncErrorDialog.Companion.newInstance @@ -246,6 +248,7 @@ open class DeckPicker : CsvImportResultLauncherProvider, CollectionPermissionScreenLauncher { val viewModel: DeckPickerViewModel by viewModels() + private val importViewModel: ImportViewModel by viewModels() private lateinit var binding: ActivityHomescreenBinding @@ -533,6 +536,51 @@ open class DeckPicker : lifecycleScope.launch { applyDeckPickerBackground() } + // Observe import requests emitted by ImportDialog via ImportViewModel. + lifecycleScope.launch { + importViewModel.pendingImportRequest + .filterNotNull() + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .collectLatest { request -> + runCatching { + showImportDialog(request.dialogType, request.importPath) + }.onFailure { exception -> + if (exception is IllegalStateException) { + showSimpleNotification( + getString(R.string.import_title), + getString(R.string.import_interrupted), + Channel.GENERAL, + ) + } else { + throw exception + } + }.onSuccess { + importViewModel.clearImportRequest() + } + } + // If an external intent saved a pending import request (see ImportUtils), restore it now. + sharedPrefs().getString("pending_import_path", null)?.let { pendingPath -> + val typeCode = sharedPrefs().getInt("pending_import_dialog_type", -1) + val dialogType = + if (typeCode == com.ichi2.anki.dialogs.ImportDialog.Type.DIALOG_IMPORT_REPLACE_CONFIRM.code) { + com.ichi2.anki.dialogs.ImportDialog.Type.DIALOG_IMPORT_REPLACE_CONFIRM + } else { + com.ichi2.anki.dialogs.ImportDialog.Type.DIALOG_IMPORT_ADD_CONFIRM + } + + importViewModel.registerImportRequest( + ImportViewModel.ImportRequest( + dialogType = dialogType, + importPath = pendingPath, + ), + ) + sharedPrefs().edit { + remove("pending_import_path") + remove("pending_import_dialog_type") + } + } + } + pullToSyncWrapper = deckPickerBinding.pullToSyncWrapper.apply { setDistanceToTriggerSync(SWIPE_TO_SYNC_TRIGGER_DISTANCE) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DialogHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DialogHandler.kt index 70c973fe2d17..db18ae4693b3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DialogHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DialogHandler.kt @@ -29,7 +29,6 @@ import com.ichi2.anki.analytics.UsageAnalytics import com.ichi2.anki.dialogs.DialogHandler.Companion.storeMessage import com.ichi2.anki.showError import com.ichi2.utils.HandlerUtils.getDefaultLooper -import com.ichi2.utils.ImportUtils import timber.log.Timber import java.lang.ref.WeakReference @@ -119,8 +118,6 @@ abstract class DialogHandlerMessage protected constructor( fun fromMessage(message: Message): DialogHandlerMessage = when (WhichDialogHandler.fromInt(message.what)) { WhichDialogHandler.MSG_SHOW_COLLECTION_LOADING_ERROR_DIALOG -> CollectionLoadingErrorDialog() - WhichDialogHandler.MSG_SHOW_COLLECTION_IMPORT_REPLACE_DIALOG -> ImportUtils.CollectionImportReplace.fromMessage(message) - WhichDialogHandler.MSG_SHOW_COLLECTION_IMPORT_ADD_DIALOG -> ImportUtils.CollectionImportAdd.fromMessage(message) WhichDialogHandler.MSG_SHOW_SYNC_ERROR_DIALOG -> SyncErrorDialog.SyncErrorDialogMessageHandler.fromMessage(message) WhichDialogHandler.MSG_SHOW_DATABASE_ERROR_DIALOG -> DatabaseErrorDialog.ShowDatabaseErrorDialog.fromMessage(message) WhichDialogHandler.MSG_DO_SYNC -> IntentHandler.Companion.DoSync() @@ -134,8 +131,6 @@ abstract class DialogHandlerMessage protected constructor( val what: Int, ) { MSG_SHOW_COLLECTION_LOADING_ERROR_DIALOG(0), - MSG_SHOW_COLLECTION_IMPORT_REPLACE_DIALOG(1), - MSG_SHOW_COLLECTION_IMPORT_ADD_DIALOG(2), MSG_SHOW_SYNC_ERROR_DIALOG(3), MSG_SHOW_DATABASE_ERROR_DIALOG(6), MSG_DO_SYNC(8), diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt index 681e75d6730b..bb002aac4758 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt @@ -20,9 +20,11 @@ import android.os.Bundle import androidx.annotation.CheckResult import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf +import androidx.lifecycle.ViewModelProvider import com.ichi2.anki.R import com.ichi2.anki.dialogs.ImportDialog.Type.DIALOG_IMPORT_ADD_CONFIRM import com.ichi2.anki.dialogs.ImportDialog.Type.DIALOG_IMPORT_REPLACE_CONFIRM +import com.ichi2.anki.dialogs.ImportViewModel import com.ichi2.anki.utils.ext.dismissAllDialogFragments import com.ichi2.utils.negativeButton import com.ichi2.utils.positiveButton @@ -42,6 +44,13 @@ class ImportDialog : AsyncDialogFragment() { private val packagePath: String get() = requireArguments().getString(IMPORT_DIALOG_PACKAGE_PATH_KEY)!! + private lateinit var importViewModel: ImportViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + importViewModel = ViewModelProvider(requireActivity()).get(ImportViewModel::class.java) + } + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { super.onCreate(savedInstanceState) val dialog = AlertDialog.Builder(requireActivity()) @@ -54,7 +63,12 @@ class ImportDialog : AsyncDialogFragment() { .setTitle(R.string.import_title) .setMessage(res().getString(R.string.import_dialog_message_add, displayFileName)) .positiveButton(R.string.import_message_add) { - (activity as ImportDialogListener).importAdd(packagePath) + importViewModel.registerImportRequest( + ImportViewModel.ImportRequest( + dialogType = DIALOG_IMPORT_ADD_CONFIRM, + importPath = packagePath, + ), + ) activity?.dismissAllDialogFragments() }.negativeButton(R.string.dialog_cancel) .create() @@ -64,7 +78,12 @@ class ImportDialog : AsyncDialogFragment() { .setTitle(R.string.import_title) .setMessage(res().getString(R.string.import_message_replace_confirm, displayFileName)) .positiveButton(R.string.dialog_positive_replace) { - (activity as ImportDialogListener).importReplace(packagePath) + importViewModel.registerImportRequest( + ImportViewModel.ImportRequest( + dialogType = DIALOG_IMPORT_REPLACE_CONFIRM, + importPath = packagePath, + ), + ) activity?.dismissAllDialogFragments() }.negativeButton(R.string.dialog_cancel) .create() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportViewModel.kt new file mode 100644 index 000000000000..c381decaa261 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportViewModel.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Georgios Michelakis + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.dialogs + +import android.os.Parcelable +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +class ImportViewModel : ViewModel() { + private val pendingImportRequestState = MutableStateFlow(null) + + val pendingImportRequest: StateFlow = pendingImportRequestState + + fun registerImportRequest(request: ImportRequest) { + Timber.d("Import dialog requested: %s", request.dialogType) + pendingImportRequestState.value = request + } + + fun clearImportRequest() { + Timber.d("Clearing pending import dialog request") + pendingImportRequestState.value = null + } + + @Parcelize + data class ImportRequest( + val dialogType: ImportDialog.Type, + val importPath: String, + ) : Parcelable +} diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt index 811b6efd19e6..6b13a4f83933 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt @@ -22,12 +22,12 @@ import android.content.ContentResolver import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Message import android.provider.OpenableColumns import androidx.annotation.CheckResult import androidx.appcompat.app.AlertDialog -import androidx.core.os.bundleOf -import com.ichi2.anki.AnkiActivity +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.R import com.ichi2.anki.common.annotations.NeedsTest @@ -36,12 +36,11 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.exception.ManuallyReportedException import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.compat.CompatHelper -import com.ichi2.anki.dialogs.DialogHandler -import com.ichi2.anki.dialogs.DialogHandlerMessage import com.ichi2.anki.dialogs.ImportDialog +import com.ichi2.anki.dialogs.ImportViewModel import com.ichi2.anki.onSelectedCsvForImport +import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.servicelayer.DebugInfoService -import com.ichi2.anki.showImportDialog import kotlinx.coroutines.launch import org.jetbrains.annotations.Contract import timber.log.Timber @@ -246,7 +245,7 @@ object ImportUtils { exception = details.userFacingException, ) } - sendShowImportFileDialogMsg(tempOutDir) + sendShowImportFileDialogMsg(context, tempOutDir) return ImportResult.Success } @@ -466,21 +465,44 @@ object ImportUtils { } /** - * Send a Message to AnkiDroidApp so that the DialogMessageHandler shows the Import apkg dialog. + * Register a pending import dialog request so an active activity can show it when resumed. + * + * @param context context used to resolve the owning [ImportViewModel] * @param importPath path of to apkg file which will be imported */ - private fun sendShowImportFileDialogMsg(importPath: String) { + private fun sendShowImportFileDialogMsg( + context: Context, + importPath: String, + ) { // Get the filename from the path val filename = File(importPath).name - val dialogMessage = + val dialogType = if (isCollectionPackage(filename)) { - CollectionImportReplace(importPath) + ImportDialog.Type.DIALOG_IMPORT_REPLACE_CONFIRM } else { - CollectionImportAdd(importPath) + ImportDialog.Type.DIALOG_IMPORT_ADD_CONFIRM + } + // Register import request or persist fallback + if (context is androidx.fragment.app.FragmentActivity) { + val activity = context + val importViewModel = androidx.lifecycle.ViewModelProvider(activity)[ImportViewModel::class.java] + importViewModel.registerImportRequest( + ImportViewModel.ImportRequest( + dialogType = dialogType, + importPath = importPath, + ), + ) + } else { + try { + AnkiDroidApp.instance.sharedPrefs().edit { + putString("pending_import_path", importPath) + putInt("pending_import_dialog_type", dialogType.code) + } + } catch (e: Exception) { + Timber.w(e, "Failed to persist pending import request") } - // Store the message in AnkiDroidApp message holder, which is loaded later in AnkiActivity.onResume - DialogHandler.storeMessage(dialogMessage.toMessage()) + } } @SuppressLint("LocaleRootUsage") @@ -503,52 +525,6 @@ object ImportUtils { } } } - - /** Show confirmation dialog asking to confirm import with replace when file called "collection.apkg" */ - class CollectionImportReplace( - private val importPath: String, - ) : DialogHandlerMessage( - which = WhichDialogHandler.MSG_SHOW_COLLECTION_IMPORT_REPLACE_DIALOG, - analyticName = "ImportReplaceDialog", - ) { - override fun handleAsyncMessage(activity: AnkiActivity) { - // Handle import of collection package APKG - activity.showImportDialog(ImportDialog.Type.DIALOG_IMPORT_REPLACE_CONFIRM, importPath) - } - - override fun toMessage(): Message = - Message.obtain().apply { - data = bundleOf("importPath" to importPath) - what = this@CollectionImportReplace.what - } - - companion object { - fun fromMessage(message: Message): CollectionImportReplace = CollectionImportReplace(message.data.getString("importPath")!!) - } - } - - /** Show confirmation dialog asking to confirm import with add */ - class CollectionImportAdd( - private val importPath: String, - ) : DialogHandlerMessage( - WhichDialogHandler.MSG_SHOW_COLLECTION_IMPORT_ADD_DIALOG, - "ImportAddDialog", - ) { - override fun handleAsyncMessage(activity: AnkiActivity) { - // Handle import of deck package APKG - activity.showImportDialog(ImportDialog.Type.DIALOG_IMPORT_ADD_CONFIRM, importPath) - } - - override fun toMessage(): Message = - Message.obtain().apply { - data = bundleOf("importPath" to importPath) - what = this@CollectionImportAdd.what - } - - companion object { - fun fromMessage(message: Message): CollectionImportAdd = CollectionImportAdd(message.data.getString("importPath")!!) - } - } } sealed class ImportResult {