Skip to content

Commit 1127e50

Browse files
committed
fix: external directory not created for profile
1 parent 89cf856 commit 1127e50

3 files changed

Lines changed: 121 additions & 4 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@ import android.webkit.WebView
2626
import androidx.annotation.VisibleForTesting
2727
import androidx.core.content.ContextCompat
2828
import androidx.core.content.edit
29-
import com.ichi2.anki.IntentHandler
29+
import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH
30+
import com.ichi2.anki.CollectionHelper.getDefaultAnkiDroidDirectory
3031
import com.ichi2.anki.common.crashreporting.CrashReportService
3132
import com.ichi2.anki.common.time.TimeManager
3233
import com.ichi2.anki.common.time.getTimestamp
33-
import org.acra.ACRA
34+
import com.ichi2.anki.preferences.sharedPrefs
3435
import org.json.JSONObject
3536
import timber.log.Timber
3637
import java.io.File
37-
import java.util.UUID
3838

3939
/**
4040
* Manages the creation, loading, and switching of user profiles.
@@ -169,12 +169,14 @@ class ProfileManager private constructor(
169169
val profileBaseDir = resolveProfileDirectory(profileId)
170170

171171
try {
172-
activeProfileContext =
172+
val wrapper =
173173
ProfileContextWrapper.create(
174174
context = appContext,
175175
profileId = profileId,
176176
profileBaseDir = profileBaseDir.file,
177177
)
178+
activeProfileContext = wrapper
179+
ensureProfileCollectionPath(wrapper)
178180
} catch (e: Exception) {
179181
Timber.w(e, "Failed to load profile context for $profileId")
180182
throw RuntimeException("Failed to load profile environment", e)
@@ -183,6 +185,34 @@ class ProfileManager private constructor(
183185
Timber.d("Profile loaded: $profileId at ${profileBaseDir.file.absolutePath}")
184186
}
185187

188+
/**
189+
* Ensures that a valid collection path is initialized and stored in the profile's shared preferences.
190+
*
191+
* For non-default profiles, this method mirrors the standard AnkiDroid directory structure
192+
* by creating a profile-specific subdirectory within the external files directory.
193+
*
194+
* @param wrapper The [ProfileContextWrapper] providing access to the profile-namespace SharedPreferences.
195+
*
196+
* @throws com.ichi2.anki.exception.SystemStorageException
197+
* if the app's external storage is unavailable (surfaced by
198+
* [getDefaultAnkiDroidDirectory]). The profile cannot be loaded without writable
199+
* external storage.
200+
*
201+
* @see PREF_COLLECTION_PATH
202+
*/
203+
private fun ensureProfileCollectionPath(wrapper: ProfileContextWrapper) {
204+
val profileId = wrapper.profileId
205+
if (profileId.isDefault()) return
206+
207+
val prefs = wrapper.sharedPrefs()
208+
if (prefs.getString(PREF_COLLECTION_PATH, null) != null) return
209+
210+
val profileCollectionDir =
211+
getDefaultAnkiDroidDirectory(appContext, directoryName = profileId.value).apply { mkdirs() }
212+
213+
prefs.edit { putString(PREF_COLLECTION_PATH, profileCollectionDir.absolutePath) }
214+
}
215+
186216
private fun configureWebView(profileId: ProfileId) {
187217
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
188218
CookieManager.getInstance().removeAllCookies(null)

AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileContextWrapperTest.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,44 @@ class ProfileContextWrapperTest {
219219
assertEquals(baseContext.filesDir.absolutePath, result.absolutePath)
220220
assertNotEquals(File(profileBaseDir, "files").absolutePath, result.absolutePath)
221221
}
222+
223+
@Test
224+
fun `all profile internal directories sit under profileBaseDir`() {
225+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
226+
val rootPrefix = profileBaseDir.absolutePath + File.separator
227+
228+
assertTrue("filesDir", wrapper.filesDir.absolutePath.startsWith(rootPrefix))
229+
assertTrue("cacheDir", wrapper.cacheDir.absolutePath.startsWith(rootPrefix))
230+
assertTrue("codeCacheDir", wrapper.codeCacheDir.absolutePath.startsWith(rootPrefix))
231+
assertTrue("noBackupFilesDir", wrapper.noBackupFilesDir.absolutePath.startsWith(rootPrefix))
232+
assertTrue(
233+
"databasePath",
234+
wrapper.getDatabasePath("collection.anki2").absolutePath.startsWith(rootPrefix),
235+
)
236+
assertTrue(
237+
"getDir",
238+
wrapper.getDir("acra", Context.MODE_PRIVATE).absolutePath.startsWith(rootPrefix),
239+
)
240+
}
241+
242+
@Test
243+
fun `two non-default profiles have disjoint internal directory trees`() {
244+
val appDataRoot = baseContext.filesDir.parentFile!!
245+
val profileA = ProfileId("p_alpha")
246+
val profileB = ProfileId("p_bravo")
247+
val baseA = File(appDataRoot, profileA.value).apply { deleteRecursively() }
248+
val baseB = File(appDataRoot, profileB.value).apply { deleteRecursively() }
249+
250+
val wrapperA = ProfileContextWrapper.create(baseContext, profileA, baseA)
251+
val wrapperB = ProfileContextWrapper.create(baseContext, profileB, baseB)
252+
253+
assertNotEquals(wrapperA.filesDir.absolutePath, wrapperB.filesDir.absolutePath)
254+
assertNotEquals(wrapperA.cacheDir.absolutePath, wrapperB.cacheDir.absolutePath)
255+
assertNotEquals(wrapperA.codeCacheDir.absolutePath, wrapperB.codeCacheDir.absolutePath)
256+
assertNotEquals(wrapperA.noBackupFilesDir.absolutePath, wrapperB.noBackupFilesDir.absolutePath)
257+
assertNotEquals(
258+
wrapperA.getDatabasePath("collection.anki2").absolutePath,
259+
wrapperB.getDatabasePath("collection.anki2").absolutePath,
260+
)
261+
}
222262
}

AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ import android.webkit.WebView
2525
import androidx.core.content.edit
2626
import androidx.test.core.app.ApplicationProvider
2727
import androidx.test.ext.junit.runners.AndroidJUnit4
28+
import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH
2829
import com.ichi2.anki.multiprofile.ProfileManager.Companion.KEY_LAST_ACTIVE_PROFILE_ID
2930
import com.ichi2.anki.multiprofile.ProfileManager.Companion.PROFILE_REGISTRY_FILENAME
31+
import com.ichi2.anki.preferences.sharedPrefs
3032
import io.mockk.every
3133
import io.mockk.just
3234
import io.mockk.mockk
@@ -237,6 +239,31 @@ class ProfileManagerTest {
237239
)
238240
}
239241

242+
@Test
243+
fun `loading a non-default profile sets deckPath to the profile-specific external dir`() {
244+
val manager = ProfileManager.create(context)
245+
val ashishId = manager.createNewProfile(ProfileName.fromTrustedSource("Ashish"))
246+
with(ProfileManager.ProfileSwitchContext) { manager.switchActiveProfile(ashishId) }
247+
248+
val reloaded = ProfileManager.create(context)
249+
val deckPath = reloaded.activeProfileContext.sharedPrefs().getString(PREF_COLLECTION_PATH, null)
250+
251+
val expected = File(context.getExternalFilesDir(null), ashishId.value).absolutePath
252+
assertEquals(expected, deckPath)
253+
}
254+
255+
@Test
256+
fun `loading a non-default profile creates the deckPath directory on disk`() {
257+
val manager = ProfileManager.create(context)
258+
val ashishId = manager.createNewProfile(ProfileName.fromTrustedSource("Ashish"))
259+
with(ProfileManager.ProfileSwitchContext) { manager.switchActiveProfile(ashishId) }
260+
261+
val reloaded = ProfileManager.create(context)
262+
val deckPath = reloaded.activeProfileContext.sharedPrefs().getString(PREF_COLLECTION_PATH, null)!!
263+
264+
assertTrue("deckPath directory must exist after profile load", File(deckPath).isDirectory)
265+
}
266+
240267
@Test
241268
fun `renameProfile does not write to disk if name is identical`() {
242269
val manager = ProfileManager.create(context)
@@ -263,4 +290,24 @@ class ProfileManagerTest {
263290

264291
assertTrue(exception.message!!.contains("not found"))
265292
}
293+
294+
@Test
295+
fun `reloading an existing profile does not overwrite a pre-existing deckPath`() {
296+
val manager = ProfileManager.create(context)
297+
val newId = manager.createNewProfile(ProfileName.fromTrustedSource("Work"))
298+
with(ProfileManager.ProfileSwitchContext) { manager.switchActiveProfile(newId) }
299+
300+
// First load materializes deckPath. Then the user "relocates" their collection.
301+
val firstLoad = ProfileManager.create(context)
302+
val userChosenPath = File(context.filesDir, "user_relocated").apply { mkdirs() }.absolutePath
303+
firstLoad.activeProfileContext.sharedPrefs().edit(commit = true) {
304+
putString(PREF_COLLECTION_PATH, userChosenPath)
305+
}
306+
307+
// Simulate app restart - ProfileManager.create runs again.
308+
val reloaded = ProfileManager.create(context)
309+
val deckPath = reloaded.activeProfileContext.sharedPrefs().getString(PREF_COLLECTION_PATH, null)
310+
311+
assertEquals("User-relocated deckPath must not be overwritten on reload", userChosenPath, deckPath)
312+
}
266313
}

0 commit comments

Comments
 (0)