diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index 7ab079fc0773..e7b8cfd8cec4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -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__*.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__*.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() } + } + + /** + * 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: `/AnkiDroid/` (the historical layout). + * Non-default profile: `//` + */ + 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? { + val externalFilesDir = appContext.getExternalFilesDir(null) ?: return null + return if (profileId.isDefault()) { + File(externalFilesDir, "AnkiDroid") + } else { + File(externalFilesDir, profileId.value) + } + } + + /** + * 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" + 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 + } + + 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", + ) + /** * Factory method to safely create and initialize the ProfileManager. * Guaranteed to return a ProfileManager with a valid [activeProfileContext]. diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt index b2b7f5a1e63b..7e59d4d1f3c7 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -25,17 +25,21 @@ import android.webkit.WebView import androidx.core.content.edit import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH +import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.multiprofile.ProfileManager.Companion.KEY_LAST_ACTIVE_PROFILE_ID import com.ichi2.anki.multiprofile.ProfileManager.Companion.PROFILE_REGISTRY_FILENAME import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkAll import io.mockk.verify import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -43,10 +47,12 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.runner.RunWith import org.robolectric.annotation.Config import java.io.File +import kotlin.test.assertNotEquals @RunWith(AndroidJUnit4::class) class ProfileManagerTest { private lateinit var context: Context + private val appDataRoot: File by lazy { context.filesDir.parentFile!! } private val prefs: SharedPreferences get() = context.getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE) @@ -263,4 +269,227 @@ class ProfileManagerTest { assertTrue(exception.message!!.contains("not found")) } + + @Test + fun `deleteProfile removes profile from registry`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Temporary")) + + manager.deleteProfile(profileId) + + val allProfiles = manager.getAllProfiles() + assertFalse(allProfiles.containsKey(profileId)) + } + + @Test + fun `deleteProfile does not remove other profiles`() { + val manager = ProfileManager.create(context) + val keep = manager.createNewProfile(ProfileName.fromTrustedSource("Keep")) + val remove = manager.createNewProfile(ProfileName.fromTrustedSource("Remove")) + + manager.deleteProfile(remove) + + val allProfiles = manager.getAllProfiles() + assertEquals(2, allProfiles.size) + assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) + assertTrue(allProfiles.containsKey(keep)) + } + + @Test + fun `deleteProfile allows deleting default when not active`() { + val manager = ProfileManager.create(context) + val other = manager.createNewProfile(ProfileName.fromTrustedSource("Other")) + + prefs.edit(commit = true) { + putString(KEY_LAST_ACTIVE_PROFILE_ID, other.value) + } + + val freshManager = ProfileManager.create(context) + freshManager.deleteProfile(ProfileId.DEFAULT) + + assertFalse(freshManager.getAllProfiles().containsKey(ProfileId.DEFAULT)) + } + + @Test(expected = IllegalArgumentException::class) + fun `deleteProfile throws when deleting active profile`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Active")) + + prefs.edit(commit = true) { + putString(KEY_LAST_ACTIVE_PROFILE_ID, profileId.value) + } + + val freshManager = ProfileManager.create(context) + freshManager.deleteProfile(profileId) + } + + @Test + fun `deleteProfile for Default profile wipes legacy root folders but preserves Alice and Bob`() { + val manager = ProfileManager.create(context) + + val aliceId = manager.createNewProfile(ProfileName.fromTrustedSource("Alice")) + val aliceDir = File(appDataRoot, aliceId.value).apply { mkdirs() } + val alicePref = + File(appDataRoot, "shared_prefs/profile_${aliceId.value}.xml").apply { + parentFile?.mkdirs() + createNewFile() + } + + val defaultFile = + File(context.filesDir, "legacy_default.txt").apply { + parentFile?.mkdirs() + writeText("Old data") + } + val defaultWebview = File(appDataRoot, "app_webview").apply { mkdirs() } + val defaultPref = + File(appDataRoot, "shared_prefs/com.ichi2.anki_preferences.xml").apply { + createNewFile() + } + + with(ProfileManager.ProfileSwitchContext) { manager.switchActiveProfile(aliceId) } + val managerWithAlice = ProfileManager.create(context) + + managerWithAlice.deleteProfile(ProfileId.DEFAULT) + + // Default data should be gone + assertFalse("Default's filesDir content should be gone", defaultFile.exists()) + assertFalse("Default's webview folder should be gone", defaultWebview.exists()) + assertFalse("Default's specific SharedPreferences should be gone", defaultPref.exists()) + + // Alice and Global Registry remains + assertTrue("Alice's directory must not be touched", aliceDir.exists()) + assertTrue("Alice's SharedPreferences must not be touched", alicePref.exists()) + assertTrue( + "The Profile Registry file itself must not be deleted", + File(appDataRoot, "shared_prefs/$PROFILE_REGISTRY_FILENAME.xml").exists(), + ) + + assertFalse( + "Registry should no longer contain Default", + managerWithAlice.getAllProfiles().containsKey(ProfileId.DEFAULT), + ) + } + + @Test + fun `deleteProfile with user-relocated collection only removes known AnkiDroid artifacts`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Relocated")) + + // Simulate a user who has moved their collection to an arbitrary directory + // (e.g. /Pictures/MyAnki/) that also contains unrelated personal files. + val userDir = File(appDataRoot, "user_pictures_like").apply { mkdirs() } + writeProfileCollectionPath(profileId, userDir) + + val colDb = File(userDir, "collection.anki2").apply { createNewFile() } + val noMedia = File(userDir, ".nomedia").apply { createNewFile() } + val mediaFolder = + File(userDir, "collection.media").apply { + mkdirs() + File(this, "img_123.jpg").createNewFile() + } + val backupFolder = + File(userDir, "backup").apply { + mkdirs() + File(this, "collection-2026-04-18-10-30.colpkg").createNewFile() + } + + // Unrelated user files + val holidayPhoto = File(userDir, "holiday.jpg").apply { writeText("pixel data") } + val unrelatedFolder = + File(userDir, "family").apply { + mkdirs() + File(this, "photo.png").createNewFile() + } + + manager.deleteProfile(profileId) + + assertFalse("collection.anki2 should be deleted", colDb.exists()) + assertFalse(".nomedia should be deleted", noMedia.exists()) + assertFalse("collection.media folder should be deleted", mediaFolder.exists()) + assertFalse("backup folder should be deleted", backupFolder.exists()) + + assertTrue("Unrelated holiday.jpg MUST survive", holidayPhoto.exists()) + assertTrue("Unrelated family/ MUST survive", unrelatedFolder.exists()) + assertTrue( + "Unrelated file inside family/ MUST survive", + File(unrelatedFolder, "photo.png").exists(), + ) + assertTrue( + "User's own directory must not be removed because it still has their data", + userDir.exists(), + ) + } + + @Test + fun `deleteProfile removes the collection directory only when it becomes empty`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Clean")) + + val collectionDir = File(appDataRoot, "standalone_collection").apply { mkdirs() } + writeProfileCollectionPath(profileId, collectionDir) + + File(collectionDir, "collection.anki2").createNewFile() + File(collectionDir, ".nomedia").createNewFile() + + manager.deleteProfile(profileId) + + assertFalse( + "Collection dir should be removed once it has no remaining contents", + collectionDir.exists(), + ) + } + + @Test + fun `deleteProfile recursively removes known subfolders inside a relocated collection`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("MediaAndBackups")) + + val collectionDir = File(appDataRoot, "custom_col").apply { mkdirs() } + writeProfileCollectionPath(profileId, collectionDir) + + val mediaNested = File(File(collectionDir, "collection.media"), "sub/deep").apply { mkdirs() } + val deepMediaFile = File(mediaNested, "pic.jpg").apply { createNewFile() } + val backupFile = + File(File(collectionDir, "backup"), "col.colpkg").apply { + parentFile?.mkdirs() + createNewFile() + } + + manager.deleteProfile(profileId) + + assertFalse("Nested media file should be deleted recursively", deepMediaFile.exists()) + assertFalse("Nested media dir should be deleted recursively", mediaNested.exists()) + assertFalse("Backup file should be deleted recursively", backupFile.exists()) + } + + @Test + fun `deleteProfile recursively removes media-trash folder`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("WithTrash")) + + val collectionDir = File(appDataRoot, "trash_col").apply { mkdirs() } + writeProfileCollectionPath(profileId, collectionDir) + + val trashFile = + File(File(collectionDir, "media.trash"), "deleted_img.jpg").apply { + parentFile?.mkdirs() + createNewFile() + } + + manager.deleteProfile(profileId) + + assertFalse("media.trash contents should be deleted", trashFile.exists()) + assertFalse("media.trash folder should be deleted", trashFile.parentFile!!.exists()) + } + + private fun writeProfileCollectionPath( + profileId: ProfileId, + dir: File, + ) { + val defaultPrefsName = "${context.packageName}_preferences" + val prefsName = "profile_${profileId.value}_$defaultPrefsName" + context + .getSharedPreferences(prefsName, Context.MODE_PRIVATE) + .edit(commit = true) { putString(PREF_COLLECTION_PATH, dir.absolutePath) } + } }