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
4 changes: 4 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ 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.ProfileId
import com.ichi2.anki.multiprofile.ProfileManager
import com.ichi2.anki.multiprofile.isPhoenixProcess
import com.ichi2.anki.navigation.initializeNavigator
Expand Down Expand Up @@ -668,6 +669,9 @@ open class AnkiDroidApp :

fun wrapForActiveProfile(base: Context): Context = (base.applicationContext as? AnkiDroidApp)?.profileManager?.wrap(base) ?: base

fun activeProfileId(context: Context): ProfileId =
(context.applicationContext as? AnkiDroidApp)?.profileManager?.activeProfileContext?.profileId ?: ProfileId.DEFAULT

/** HACK: Whether an exception report has been thrown - TODO: Rewrite an ACRA Listener to do this */
@VisibleForTesting
var sentExceptionReportHack = false
Expand Down
4 changes: 2 additions & 2 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2315,7 +2315,7 @@ open class DeckPicker :
// This code should not be reachable with lower versions
val shortcut =
ShortcutInfoCompat
.Builder(this, shortcutData.deckId.toString())
.Builder(this, deckShortcutId(shortcutData.deckId))
.setIntent(
with(DeferredNavigation) { ReviewDeckDestination.ExternalLaunch(shortcutData.deckId).toIntent() },
).setIcon(IconCompat.createWithResource(this, R.mipmap.ic_launcher))
Expand Down Expand Up @@ -2350,7 +2350,7 @@ open class DeckPicker :
@NeedsTest("ensure collapsed decks are also deleted")
private fun disableDeckAndChildrenShortcuts(deckTreeDids: List<String>) {
val errorMessage: CharSequence = getString(CommonString.deck_shortcut_doesnt_exist)
ShortcutUtils.disableShortcuts(this, deckTreeDids, errorMessage)
ShortcutUtils.disableShortcuts(this, deckTreeDids.map { deckShortcutId(it.toLong()) }, errorMessage)
}

fun renameDeckDialog(did: DeckId) {
Expand Down
22 changes: 21 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.ichi2.anki.dialogs.DialogHandler.Companion.storeMessage
import com.ichi2.anki.dialogs.DialogHandlerMessage
import com.ichi2.anki.dialogs.requireDeckPickerOrShowError
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.multiprofile.ProfileId
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.shareddeck.SharedDecksDownloadFragment
import com.ichi2.anki.sync.MeteredSyncPolicy
Expand Down Expand Up @@ -196,6 +197,13 @@ class IntentHandler : AbstractIntentHandler() {
) {
val deckId = intent.getLongExtra(EXTRA_DECK_ID, 0)
Timber.i("Handling intent to review deck '%d'", deckId)
val deckProfile = intent.getStringExtra(EXTRA_PROFILE_ID)?.let(::ProfileId) ?: ProfileId.DEFAULT
if (deckProfile != AnkiDroidApp.activeProfileId(this)) {
Timber.w("Not reviewing deck '%d': it belongs to another profile", deckId)
showThemedToast(this, otherProfileDeckMessage(deckProfile), false)
launchDeckPickerIfNoOtherTasks(reloadIntent)
return
}

val reviewIntent =
if (Prefs.isNewStudyScreenEnabled) {
Expand All @@ -216,6 +224,17 @@ class IntentHandler : AbstractIntentHandler() {
finish()
}

private fun otherProfileDeckMessage(profileId: ProfileId): String {
val profileName =
AnkiDroidApp.instance.profileManager
?.getAllProfiles()
?.get(profileId)
?.displayName
?.value
?: return getString(CommonString.deck_shortcut_doesnt_exist)
return getString(CommonString.deck_in_other_profile, profileName)
}

private fun handleSyncIntent(
reloadIntent: Intent,
action: String?,
Expand Down Expand Up @@ -383,6 +402,7 @@ class IntentHandler : AbstractIntentHandler() {
companion object {
private const val ACTION_DO_SYNC = "com.ichi2.anki.DO_SYNC"
const val EXTRA_DECK_ID = "EXTRA_DECK_ID"
const val EXTRA_PROFILE_ID = "EXTRA_PROFILE_ID"
private const val ACTION_COPY_DEBUG_INFO = "com.ichi2.anki.COPY_DEBUG_INFO"
private const val EXTRA_CLIPBOARD_DATA = "clip_data"

Expand Down Expand Up @@ -525,6 +545,6 @@ class IntentHandler : AbstractIntentHandler() {
fun getReviewDeckIntent(
context: Context,
deckId: DeckId,
): Intent = Intent(context, IntentHandler::class.java).putExtra(EXTRA_DECK_ID, deckId)
): Intent = Intent(context, IntentHandler::class.java).putExtra(EXTRA_DECK_ID, deckId).putDeckProfile(context)
}
}
14 changes: 14 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/ReviewDeckDestination.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ package com.ichi2.anki
import android.content.Context
import android.content.Intent
import com.ichi2.anki.IntentHandler.Companion.EXTRA_DECK_ID
import com.ichi2.anki.IntentHandler.Companion.EXTRA_PROFILE_ID
import com.ichi2.anki.common.destinations.ReviewDeckDestination
import com.ichi2.anki.libanki.DeckId

/** Builds the [Intent] that opens the reviewer for this destination. */
fun ReviewDeckDestination.toIntent(context: Context): Intent =
Expand All @@ -16,5 +18,17 @@ fun ReviewDeckDestination.toIntent(context: Context): Intent =
// shortcuts require the intent to have an action
setAction(Intent.ACTION_VIEW)
putExtra(EXTRA_DECK_ID, deckId)
putDeckProfile(context)
}
}

fun Intent.putDeckProfile(context: Context): Intent =
apply {
val profileId = AnkiDroidApp.activeProfileId(context)
if (!profileId.isDefault()) putExtra(EXTRA_PROFILE_ID, profileId.value)
}

fun Context.deckShortcutId(deckId: DeckId): String {
val profileId = AnkiDroidApp.activeProfileId(this)
return if (profileId.isDefault()) deckId.toString() else "${profileId.value}_$deckId"
}
14 changes: 11 additions & 3 deletions AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ package com.ichi2.anki.multiprofile

import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import androidx.work.WorkManager
import androidx.work.await
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.backupLock
import com.ichi2.anki.common.destinations.LauncherDestination
import com.ichi2.anki.reviewreminders.ReviewReminderAlarmManager
import com.ichi2.anki.toIntent
import com.ichi2.anki.worker.UniqueWorkNames
import com.ichi2.anki.worker.mediaSyncLock
Expand All @@ -24,7 +26,10 @@ import java.io.File
*
* Sync work is cancelled once the sync lock is held, since it carries the current
* profile's AnkiWeb key. The work locks stay held until ProcessPhoenix kills this
* process, so nothing can start and reopen the collection in the meantime.
* process, so nothing can start and reopen the collection in the meantime. The
* collection is closed and handed over in one step on its queue, so an operation
* queued behind the restart cannot reopen it first. The notifications and reminder
* alarms of the profile being left are cleared, as the next profile can't act on them.
*/
suspend fun safeRestartApp(context: Context) {
Timber.i("Restarting the app")
Expand All @@ -35,8 +40,11 @@ suspend fun safeRestartApp(context: Context) {
}
mediaSyncLock.holdUnlessThrows {
backupLock.holdUnlessThrows {
CollectionManager.ensureClosed()
ProcessPhoenix.triggerRebirth(context, LauncherDestination.toIntent(context))
CollectionManager.withClosedCollection {
ReviewReminderAlarmManager.unscheduleAllReviewReminderNotifications(context)
NotificationManagerCompat.from(context).cancelAll()
ProcessPhoenix.triggerRebirth(context, LauncherDestination.toIntent(context))
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,28 @@ class ProfileManager private constructor(
* Persists [newProfileId] as the active profile.
*
* @param newProfileId The [ProfileId] to activate on next launch.
* @return false if the new profile could not be set up or the switch could not be saved,
* in which case the current profile stays active
*/
@VisibleForTesting
context(_: ProfileSwitchContext)
fun switchActiveProfile(newProfileId: ProfileId) {
fun switchActiveProfile(newProfileId: ProfileId): Boolean {
Timber.i("Switching profile to ID: $newProfileId")
try {
prepareProfile(newProfileId)
} catch (e: Exception) {
Timber.w(e, "Could not set up $newProfileId, staying on the current profile")
return false
}
// commit, not apply: the app restarts straight after a switch, and a pending apply() would be lost
profileRegistry.setLastActiveProfileId(newProfileId, commit = true)
val saved = profileRegistry.commitLastActiveProfileId(newProfileId)
if (!saved) Timber.w("Could not save the switch to $newProfileId")
return saved
}

private fun prepareProfile(profileId: ProfileId) {
val wrapper = ProfileContextWrapper.create(profileContext, profileId, resolveProfileDirectory(profileId).file)
ensureProfileCollectionPath(wrapper)
}

private fun loadProfileData(profileId: ProfileId) {
Expand Down Expand Up @@ -599,11 +614,17 @@ class ProfileManager private constructor(
return id?.let { ProfileId(it) }
}

fun setLastActiveProfileId(
id: ProfileId,
commit: Boolean = false,
) {
globalPrefs.edit(commit = commit) { putString(PREF_LAST_ACTIVE_PROFILE_ID, id.value) }
fun setLastActiveProfileId(id: ProfileId) {
globalPrefs.edit { putString(PREF_LAST_ACTIVE_PROFILE_ID, id.value) }
}

fun commitLastActiveProfileId(id: ProfileId): Boolean {
val previous = globalPrefs.getString(PREF_LAST_ACTIVE_PROFILE_ID, null)
val editor = globalPrefs.edit()
editor.putString(PREF_LAST_ACTIVE_PROFILE_ID, id.value)
if (editor.commit()) return true
globalPrefs.edit { putString(PREF_LAST_ACTIVE_PROFILE_ID, previous) }
return false
}

fun getWebViewProfileId(): ProfileId? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class ProfileSwitchGuard(
data class Blocked(
val reasons: Set<BlockReason>,
) : Result()

data object Failed : Result()
}

// TODO: COLLECTION_BUSY has no check yet: CollectionManager serialises on a
Expand Down Expand Up @@ -62,8 +64,8 @@ class ProfileSwitchGuard(
}

return if (activeBlockedReasons.isEmpty()) {
with(ProfileSwitchContext) { profileManager.switchActiveProfile(newProfileId) }
Result.Success
val switched = with(ProfileSwitchContext) { profileManager.switchActiveProfile(newProfileId) }
if (switched) Result.Success else Result.Failed
} else {
Result.Blocked(activeBlockedReasons)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ichi2.anki.AnkiDroidApp
import com.ichi2.anki.CommonString
import com.ichi2.anki.common.utils.android.showThemedToast
import com.ichi2.anki.multiprofile.safeRestartApp
import com.ichi2.compose.theme.AnkiDroidTheme
import timber.log.Timber

Expand All @@ -42,25 +43,39 @@ class SwitchProfilesFragment : Fragment() {
}
return View(requireContext())
}
val viewModel: SwitchProfilesViewModel by viewModels { SwitchProfilesViewModel.factory(profileManager) }
val appContext = requireContext().applicationContext
val viewModel: SwitchProfilesViewModel by viewModels {
SwitchProfilesViewModel.factory(profileManager) { safeRestartApp(appContext) }
}
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AnkiDroidTheme {
val profiles by viewModel.profiles.collectAsStateWithLifecycle()
val isAddProfileDialogVisible by viewModel.isAddProfileDialogVisible.collectAsStateWithLifecycle()
val profileBeingRenamed by viewModel.profileBeingRenamed.collectAsStateWithLifecycle()
val profileToSwitchTo by viewModel.profileToSwitchTo.collectAsStateWithLifecycle()
val isSwitchBlocked by viewModel.isSwitchBlocked.collectAsStateWithLifecycle()
val profileSwitchFailed by viewModel.profileSwitchFailed.collectAsStateWithLifecycle()
SwitchProfilesScreen(
profiles = profiles,
isAddProfileDialogVisible = isAddProfileDialogVisible,
profileBeingRenamed = profileBeingRenamed,
profileToSwitchTo = profileToSwitchTo,
isSwitchBlocked = isSwitchBlocked,
profileSwitchFailed = profileSwitchFailed,
onNavigateUp = { requireActivity().onBackPressedDispatcher.onBackPressed() },
onProfileClick = viewModel::requestSwitch,
onAddProfileClick = viewModel::showAddProfileDialog,
onAddProfileConfirm = viewModel::addProfile,
onAddProfileDismiss = viewModel::dismissAddProfileDialog,
onEditProfile = viewModel::showRenameProfileDialog,
onRenameProfileConfirm = viewModel::renameProfile,
onRenameProfileDismiss = viewModel::dismissRenameProfileDialog,
onSwitchProfileConfirm = viewModel::switchProfile,
onSwitchProfileDismiss = viewModel::dismissSwitchProfileDialog,
onSwitchBlockedDismiss = viewModel::dismissSwitchBlockedMessage,
onSwitchFailedDismiss = viewModel::dismissSwitchFailedMessage,
onDeleteProfile = viewModel::deleteProfile,
)
}
Expand Down
Loading
Loading