-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
[Multiple-Profile]feat: allow profile deletion #20658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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) { | ||
| /* | ||
| * 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() } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| } | ||
|
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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
|
|
@@ -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", | ||
| ) | ||
|
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]. | ||
|
|
||
There was a problem hiding this comment.
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?