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
223 changes: 221 additions & 2 deletions AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,14 @@ import android.webkit.WebView
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH
import com.ichi2.anki.IntentHandler
import com.ichi2.anki.common.crashreporting.CrashReportService
import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.common.time.getTimestamp
import org.acra.ACRA
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.util.UUID

/**
* Manages the creation, loading, and switching of user profiles.
Expand Down Expand Up @@ -266,6 +265,191 @@ class ProfileManager private constructor(
Timber.d("Renamed profile $profileId to '$newDisplayName'")
}

/**
* Permanently deletes a profile, removing its registry entry and associated data
* across both internal and external storage.
*
* - Internal storage: app-private. We own these directories, so bulk deletion is safe.
* For non-default profiles: the profile's data directory and every
* `profile_<id>_*.xml` SharedPreferences file. For the default profile:
* the legacy root folders (`files`, `cache`, `databases`, ...).
* - Collection directory: user-visible under `Android/data/.../files/` and
* relocatable by the user to any path via [PREF_COLLECTION_PATH] (e.g. `/Pictures/`).
* We therefore resolve the stored path, and only delete known AnkiDroid artifacts
* inside it - never `deleteRecursively()`.
*/
fun deleteProfile(profileId: ProfileId) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user can select an arbitrary directory: do we want to be more careful?

/*
* File-system data is removed before the registry entry. If deletion fails
* or is interrupted, the registry entry remains so the user can retry.
*/
require(profileRegistry.getLastActiveProfileId() != profileId) {
"Cannot delete the currently active profile ($profileId). Switch first."
}

val appDataRoot = ContextCompat.getDataDir(appContext)

if (profileId.isDefault()) {
deleteDefaultProfileDataOnly(appDataRoot)
} else {
val profileDir = resolveProfileDirectory(profileId).file
if (profileDir.exists()) {
profileDir.deleteRecursively()
}
deleteNamespacedSharedPreferences(appDataRoot, profileId)
}

resolveStoredCollectionDir(profileId)?.let { deleteCollectionArtifactsSafely(it) }

profileRegistry.removeProfile(profileId)
Timber.i("Deleted profile: $profileId")
}

/**
* Deletes every `profile_<id>_*.xml` file inside the app's `shared_prefs` directory.
* No-op if [appDataRoot] is null or the `shared_prefs` dir doesn't exist.
*/
private fun deleteNamespacedSharedPreferences(
appDataRoot: File?,
profileId: ProfileId,
) {
if (appDataRoot == null) return
val sharedPrefsDir = File(appDataRoot, "shared_prefs")
if (!sharedPrefsDir.exists() || !sharedPrefsDir.isDirectory) return

val prefix = "profile_${profileId.value}_"
sharedPrefsDir
.listFiles()
?.filter { it.name.startsWith(prefix) }
?.forEach { it.delete() }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd log the number of files deleted

}

/**
* Returns the collection directory for [profileId], see any user relocation via
* [PREF_COLLECTION_PATH]. Falls back to the default location if the preference is unset.
* Returns null if no candidate directory can be resolved.
*
* Default profile: `<external>/AnkiDroid/` (the historical layout).
* Non-default profile: `<external>/<profileId>/`
*/
private fun resolveStoredCollectionDir(profileId: ProfileId): File? {
val candidate: File =
readStoredCollectionPath(profileId)?.let(::File)
?: defaultCollectionDirFor(profileId)
?: return null

return candidate.takeIf { it.exists() && it.isDirectory }
}

/** The default-location fallback used when the profile has never written `PREF_COLLECTION_PATH`. */
private fun defaultCollectionDirFor(profileId: ProfileId): File? {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct, but it feels that it should be a function which is also used when the profile is created

val externalFilesDir = appContext.getExternalFilesDir(null) ?: return null
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can throw, I beleive

return if (profileId.isDefault()) {
File(externalFilesDir, "AnkiDroid")
} else {
File(externalFilesDir, profileId.value)
}
}
Comment thread
david-allison marked this conversation as resolved.

/**
* Reads [PREF_COLLECTION_PATH] from the profile's namespaced default SharedPreferences
* by filename, so we don't need to instantiate a [ProfileContextWrapper] (which has
* mkdir side effects we don't want in the delete flow).
*
* The filename format must stay in sync with [ProfileContextWrapper.getSharedPreferences].
*/
private fun readStoredCollectionPath(profileId: ProfileId): String? {
val defaultPrefsName = "${appContext.packageName}_preferences"
val prefsName =
if (profileId.isDefault()) defaultPrefsName else "profile_${profileId.value}_$defaultPrefsName"
Comment on lines +362 to +364
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although correct, it feels like these should be somewhere more central so they can't get out of sync

as an extreme abstraction:

val prefsForProfile = ...
return prefsForProfile.collectionPath

return appContext
.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
.getString(PREF_COLLECTION_PATH, null)
}

/**
* Deletes only known AnkiDroid collection artifacts inside [collectionDir].
*
* AnkiDroid lets the user point the collection path at any directory — including ones
* they also use for unrelated data, e.g. `/Pictures/`. We therefore never
* `deleteRecursively()` this directory. Files and subdirectories we don't recognize are
* left untouched.
*
* If [collectionDir] is empty after the sweep, it is also removed. If anything remains,
* we send a silent crash report
*/
private fun deleteCollectionArtifactsSafely(collectionDir: File) {
if (!collectionDir.exists() || !collectionDir.isDirectory) return

collectionDir.listFiles()?.forEach { entry ->
when {
entry.isFile && entry.name in COLLECTION_ARTIFACT_FILES -> entry.delete()
entry.isDirectory && entry.name in COLLECTION_ARTIFACT_DIRS -> entry.deleteRecursively()
else -> {
val entryType = if (entry.isDirectory) "Directory" else "File"
Timber.w("deleteProfile: leaving unknown entry untouched: <$entryType>")
}
}
}

val remaining = collectionDir.listFiles()
if (remaining.isNullOrEmpty()) {
collectionDir.delete()
return
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the folder is empty, delete it

If not, maybe send a silent exception report?


val fileCount = remaining.count { it.isFile }
val dirCount = remaining.count { it.isDirectory }

val message =
"deleteCollectionArtifactsSafely left ${remaining.size} unknown entries in collection dir (Files: $fileCount, Dirs: $dirCount)"

val silent = IllegalStateException(message)
CrashReportService.sendExceptionReport(
silent,
"ProfileManager::deleteCollectionArtifactsSafely",
)
Timber.w(silent, message)
}

/**
* Wipes the Default profile's legacy data from the root directories
* while protecting the subdirectories belonging to other profiles.
*/
private fun deleteDefaultProfileDataOnly(appDataRoot: File?) {
if (appDataRoot == null) return

val defaultFolders =
listOf(
"app_webview",
"databases",
"files",
"cache",
"code_cache",
"no_backup",
)

defaultFolders.forEach { folderName ->
val folder = File(appDataRoot, folderName)
if (folder.exists()) {
folder.deleteRecursively()
}
}

val sharedPrefsDir = File(appDataRoot, "shared_prefs")
if (sharedPrefsDir.exists() && sharedPrefsDir.isDirectory) {
sharedPrefsDir.listFiles()?.forEach { file ->
val fileName = file.name
val isRegistry = fileName == "$PROFILE_REGISTRY_FILENAME.xml"
val isOtherProfile = fileName.startsWith("profile_")

if (!isRegistry && !isOtherProfile) {
file.delete()
}
}
}
}

/**
* Holds the meta-data for a profile.
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
Expand Down Expand Up @@ -364,6 +548,18 @@ class ProfileManager private constructor(
return result
}

/**
* Removes a profile entry from the registry.
*
* Does **not** delete the profile's data directory on disk.
* Callers are responsible for cleaning up file-system resources.
*
* @param id The [ProfileId] of the profile to remove.
*/
fun removeProfile(id: ProfileId) {
globalPrefs.edit { remove(id.value) }
}

fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
}

Expand All @@ -385,6 +581,29 @@ class ProfileManager private constructor(

const val DEFAULT_PROFILE_DISPLAY_NAME = "Default"

/** Files AnkiDroid creates directly inside the collection directory. */
private val COLLECTION_ARTIFACT_FILES =
setOf(
"collection.anki2",
"collection.anki2-journal",
"collection.anki2-wal",
"collection.anki2-shm",
"collection.media.db",
"collection.media.db-journal",
"collection.media.db-wal",
"collection.media.db-shm",
".nomedia",
)

/** Subdirectories AnkiDroid creates inside the collection directory. */
private val COLLECTION_ARTIFACT_DIRS =
setOf(
"collection.media",
"media.trash",
"backup",
"broken",
)
Comment thread
david-allison marked this conversation as resolved.

/**
* Factory method to safely create and initialize the ProfileManager.
* Guaranteed to return a ProfileManager with a valid [activeProfileContext].
Expand Down
Loading
Loading