Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Comment thread
mixelas marked this conversation as resolved.
}.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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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),
Expand Down
23 changes: 21 additions & 2 deletions AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand All @@ -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()
Expand All @@ -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()
Expand Down
46 changes: 46 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Georgios Michelakis <michelakisgio@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.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<ImportRequest?>(null)

val pendingImportRequest: StateFlow<ImportRequest?> = 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(
Comment thread
mixelas marked this conversation as resolved.
val dialogType: ImportDialog.Type,
val importPath: String,
) : Parcelable
}
96 changes: 36 additions & 60 deletions AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -246,7 +245,7 @@ object ImportUtils {
exception = details.userFacingException,
)
}
sendShowImportFileDialogMsg(tempOutDir)
sendShowImportFileDialogMsg(context, tempOutDir)
return ImportResult.Success
}

Expand Down Expand Up @@ -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
Comment thread
mixelas marked this conversation as resolved.
*/
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")
Expand All @@ -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 {
Expand Down
Loading