Skip to content

Commit 93c892a

Browse files
committed
musikr.fs: prototype file tree caching
1 parent b3f51ea commit 93c892a

3 files changed

Lines changed: 234 additions & 47 deletions

File tree

musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFS.kt

Lines changed: 107 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,62 +22,69 @@ import android.content.ContentResolver
2222
import android.content.Context
2323
import android.net.Uri
2424
import android.provider.DocumentsContract
25-
import android.util.Log
26-
import kotlinx.coroutines.CoroutineScope
27-
import kotlinx.coroutines.Dispatchers
25+
import androidx.core.net.toUri
2826
import kotlinx.coroutines.ExperimentalCoroutinesApi
2927
import kotlinx.coroutines.coroutineScope
3028
import kotlinx.coroutines.flow.Flow
3129
import kotlinx.coroutines.flow.SharingStarted
30+
import kotlinx.coroutines.flow.asFlow
3231
import kotlinx.coroutines.flow.emptyFlow
3332
import kotlinx.coroutines.flow.flow
3433
import kotlinx.coroutines.flow.map
3534
import kotlinx.coroutines.flow.mapNotNull
35+
import kotlinx.coroutines.flow.merge
3636
import kotlinx.coroutines.flow.shareIn
3737
import kotlinx.coroutines.flow.takeWhile
3838
import org.oxycblt.musikr.fs.MusicLocation
3939
import org.oxycblt.musikr.fs.Path
40+
import org.oxycblt.musikr.fs.path.DocumentPathFactory
4041

4142
internal interface DeviceFS {
42-
suspend fun explore(locations: Flow<MusicLocation>): Flow<DeviceDirectory>
43+
fun explore(locations: Flow<MusicLocation>, fileTree: FileTree): Flow<DeviceDirectory>
4344

4445
companion object {
4546
fun from(context: Context, withHidden: Boolean): DeviceFS =
46-
DeviceFSImpl(context.contentResolverSafe, JsonFileTreeCache(context), withHidden)
47+
DeviceFSImpl(context.contentResolverSafe, withHidden)
4748
}
4849
}
4950

5051
@OptIn(ExperimentalCoroutinesApi::class)
5152
private class DeviceFSImpl(
5253
private val contentResolver: ContentResolver,
53-
private val cache: FileTreeCache,
5454
private val withHidden: Boolean
5555
) : DeviceFS {
56-
override fun explore(locations: Flow<MusicLocation>): Flow<DeviceDirectory> {
56+
override fun explore(locations: Flow<MusicLocation>, fileTree: FileTree) =
5757
locations.mapNotNull { location ->
58-
val treeDocumentId =
59-
DocumentsContract.getTreeDocumentId(location.uri)
60-
val uri = DocumentsContract.buildDocumentUriUsingTree(location.uri, treeDocumentId)
61-
val modifiedMs = contentResolver.useQuery(
62-
uri,
63-
arrayOf(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
64-
) { cursor ->
65-
if (!cursor.moveToFirst()) return@useQuery null
66-
val lastModifiedIndex =
67-
cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
68-
cursor.getLong(lastModifiedIndex)
69-
}
70-
if (modifiedMs == null) {
71-
return@mapNotNull null
72-
}
73-
query(
74-
location.uri,
75-
treeDocumentId,
76-
location.path,
77-
modifiedMs,
78-
null
79-
)
58+
queryRoot(location, fileTree)
8059
}
60+
61+
private suspend fun queryRoot(
62+
location: MusicLocation,
63+
fileTree: FileTree
64+
): DeviceDirectory? {
65+
val treeDocumentId =
66+
DocumentsContract.getTreeDocumentId(location.uri)
67+
val uri = DocumentsContract.buildDocumentUriUsingTree(location.uri, treeDocumentId)
68+
val modifiedMs = contentResolver.useQuery(
69+
uri,
70+
arrayOf(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
71+
) { cursor ->
72+
if (!cursor.moveToFirst()) return@useQuery null
73+
val lastModifiedIndex =
74+
cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
75+
cursor.getLong(lastModifiedIndex)
76+
}
77+
if (modifiedMs == null) {
78+
return null
79+
}
80+
return query(
81+
location.uri,
82+
treeDocumentId,
83+
location.path,
84+
modifiedMs,
85+
null,
86+
fileTree
87+
)
8188
}
8289

8390
private suspend fun query(
@@ -86,9 +93,20 @@ private class DeviceFSImpl(
8693
path: Path,
8794
modifiedMs: Long,
8895
parent: DeviceDirectory?,
96+
fileTree: FileTree
8997
): DeviceDirectory = coroutineScope {
98+
val uri = DocumentsContract.buildDocumentUriUsingTree(rootUri, treeDocumentId)
99+
val cached = fileTree.queryDirectory(uri)
100+
if (cached != null && cached.modifiedMs == modifiedMs) {
101+
return@coroutineScope hydrateCached(
102+
cached = cached,
103+
parentDir = parent,
104+
path = path,
105+
fileTree = fileTree
106+
)
107+
}
90108
val dir = DeviceDirectoryImpl(
91-
uri = DocumentsContract.buildDocumentUriUsingTree(rootUri, treeDocumentId),
109+
uri = uri,
92110
path = path,
93111
modifiedMs = modifiedMs,
94112
parent = parent,
@@ -111,6 +129,9 @@ private class DeviceFSImpl(
111129
val lastModifiedIndex =
112130
cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
113131

132+
val childSubdirUris = mutableListOf<String>()
133+
val childFileUris = mutableListOf<String>()
134+
114135
while (cursor.moveToNext()) {
115136
val childId = cursor.getString(childUriIndex)
116137
val displayName = cursor.getString(displayNameIndex)
@@ -131,8 +152,10 @@ private class DeviceFSImpl(
131152
treeDocumentId = childId,
132153
path = newPath,
133154
modifiedMs = modifiedMs,
134-
parent = dir
155+
parent = dir,
156+
fileTree = fileTree
135157
)
158+
childSubdirUris.add(subdir.uri.toString())
136159
emit(StreamedFile.More(subdir))
137160
} else {
138161
val size = cursor.getLong(sizeIndex)
@@ -146,9 +169,26 @@ private class DeviceFSImpl(
146169
modifiedMs = lastModified,
147170
parent = dir
148171
)
172+
val write = CachedFile(
173+
uri = childUri.toString(),
174+
name = displayName,
175+
modifiedMs = lastModified,
176+
mimeType = mimeType,
177+
size = size
178+
)
179+
fileTree.updateFile(childUri, write)
180+
childFileUris.add(childUri.toString())
149181
emit(StreamedFile.More(file))
150182
}
151183
}
184+
val writeDir = CachedDirectory(
185+
uri = uri.toString(),
186+
name = requireNotNull(path.name),
187+
modifiedMs = modifiedMs,
188+
subdirUris = childSubdirUris,
189+
fileUris = childFileUris
190+
)
191+
fileTree.updateDirectory(uri, writeDir)
152192
emit(StreamedFile.Done)
153193
}
154194
}.shareIn(this, SharingStarted.Eagerly, replay = Int.MAX_VALUE)
@@ -157,6 +197,42 @@ private class DeviceFSImpl(
157197
dir
158198
}
159199

200+
private fun hydrateCached(
201+
cached: CachedDirectory,
202+
parentDir: DeviceDirectory?,
203+
path: Path,
204+
fileTree: FileTree
205+
): DeviceDirectory {
206+
val dir = DeviceDirectoryImpl(
207+
uri = cached.uri.toUri(),
208+
path = path,
209+
modifiedMs = cached.modifiedMs,
210+
parent = parentDir,
211+
children = emptyFlow()
212+
)
213+
dir.children = merge(
214+
cached.subdirUris.asFlow().map { subdirUriString ->
215+
val subdirUri = subdirUriString.toUri()
216+
val cachedSubdir = requireNotNull(fileTree.queryDirectory(subdirUri)) {
217+
"No cached subdir for $subdirUri, malformed cache! Rescan needed."
218+
}
219+
hydrateCached(cachedSubdir, dir, dir.path.file(cachedSubdir.name), fileTree)
220+
},
221+
cached.fileUris.asFlow().map {
222+
val cachedFile = requireNotNull(fileTree.queryFile(it.toUri()))
223+
DeviceFile(
224+
uri = it.toUri(),
225+
path = dir.path.file(cachedFile.name),
226+
modifiedMs = cachedFile.modifiedMs,
227+
mimeType = cachedFile.mimeType,
228+
size = cachedFile.size,
229+
parent = dir
230+
)
231+
}
232+
)
233+
return dir
234+
}
235+
160236
private sealed interface StreamedFile {
161237
data class More(val value: DeviceFSEntry) : StreamedFile
162238
data object Done : StreamedFile

musikr/src/main/java/org/oxycblt/musikr/fs/device/FileTreeCache.kt

Lines changed: 119 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,39 +21,143 @@ package org.oxycblt.musikr.fs.device
2121
import android.content.Context
2222
import android.net.Uri
2323
import android.util.Log
24+
import kotlinx.coroutines.Dispatchers
25+
import kotlinx.coroutines.withContext
2426
import kotlinx.serialization.Serializable
2527
import kotlinx.serialization.json.Json
28+
import kotlinx.serialization.decodeFromString
29+
import kotlinx.serialization.encodeToString
2630
import java.io.File
2731

2832
interface FileTreeCache {
29-
suspend fun queryRootDirectory(uri: Uri): CachedRoot
30-
suspend fun updateRootDirectory(uri: Uri, cachedRoot: CachedRoot)
33+
fun read(): FileTree
34+
}
3135

32-
suspend fun queryDirectory(uri: Uri): CachedDirectory
36+
interface FileTree {
37+
suspend fun queryDirectory(uri: Uri): CachedDirectory?
3338
suspend fun updateDirectory(uri: Uri, directory: CachedDirectory)
3439

3540
suspend fun queryFile(uri: Uri): CachedFile?
3641
suspend fun updateFile(uri: Uri, file: CachedFile)
42+
43+
suspend fun write()
3744
}
3845

39-
data class CachedRoot(
40-
val uri: String,
41-
val path: String,
42-
val modifiedMs: Long,
43-
val childUris: List<String>
44-
)
46+
// Define the sealed interface
47+
@Serializable
48+
sealed interface FileSystemEntry {
49+
val uri: String
50+
val modifiedMs: Long
51+
}
4552

53+
@Serializable
4654
data class CachedDirectory(
47-
val uri: String,
55+
override val uri: String,
4856
val name: String,
49-
val modifiedMs: Long,
50-
val childUris: List<String>
51-
)
57+
override val modifiedMs: Long,
58+
val subdirUris: List<String>,
59+
val fileUris: List<String>
60+
) : FileSystemEntry
5261

62+
@Serializable
5363
data class CachedFile(
54-
val uri: String,
64+
override val uri: String,
5565
val name: String,
56-
val modifiedMs: Long,
66+
override val modifiedMs: Long,
5767
val mimeType: String,
5868
val size: Long
69+
) : FileSystemEntry
70+
71+
@Serializable
72+
data class FileTreeData(
73+
val directories: Map<String, CachedDirectory> = mapOf(),
74+
val files: Map<String, CachedFile> = mapOf()
5975
)
76+
77+
class FileTreeCacheImpl(private val context: Context) : FileTreeCache {
78+
companion object {
79+
private const val TAG = "FileTreeCache"
80+
private const val CACHE_FILENAME = "file_tree_cache.json"
81+
private val json = Json {
82+
ignoreUnknownKeys = true
83+
prettyPrint = true
84+
}
85+
}
86+
87+
override fun read(): FileTree {
88+
val cacheFile = File(context.cacheDir, CACHE_FILENAME)
89+
90+
return if (cacheFile.exists()) {
91+
try {
92+
val jsonContent = cacheFile.readText()
93+
val fileTreeCache = json.decodeFromString<FileTreeData>(jsonContent)
94+
FileTreeImpl(context, fileTreeCache.directories.toMutableMap(), fileTreeCache.files.toMutableMap())
95+
} catch (e: Exception) {
96+
Log.e(TAG, "Failed to read cache file", e)
97+
FileTreeImpl(context, mutableMapOf(), mutableMapOf())
98+
}
99+
} else {
100+
Log.i(TAG, "Cache file does not exist, creating new FileTree")
101+
FileTreeImpl(context, mutableMapOf(), mutableMapOf())
102+
}
103+
}
104+
}
105+
106+
class FileTreeImpl(
107+
private val context: Context,
108+
private val mutableDirectories: MutableMap<String, CachedDirectory> = mutableMapOf(),
109+
private val mutableFiles: MutableMap<String, CachedFile> = mutableMapOf()
110+
) : FileTree {
111+
companion object {
112+
private const val TAG = "FileTree"
113+
private const val CACHE_FILENAME = "file_tree_cache.json"
114+
private val json = Json {
115+
ignoreUnknownKeys = true
116+
prettyPrint = true
117+
}
118+
}
119+
120+
// Directory operations
121+
override suspend fun queryDirectory(uri: Uri): CachedDirectory? {
122+
Log.d("FileTreeCache", "Querying directory: $uri ${mutableDirectories[uri.toString()]}")
123+
val uriString = uri.toString()
124+
return mutableDirectories[uriString]
125+
}
126+
127+
override suspend fun updateDirectory(uri: Uri, directory: CachedDirectory) {
128+
Log.d("FileTreeCache", "Updating directory: $uri")
129+
val uriString = uri.toString()
130+
mutableDirectories[uriString] = directory
131+
}
132+
133+
// File operations
134+
override suspend fun queryFile(uri: Uri): CachedFile? {
135+
Log.d("FileTreeCache", "Querying file: $uri")
136+
val uriString = uri.toString()
137+
return mutableFiles[uriString]
138+
}
139+
140+
override suspend fun updateFile(uri: Uri, file: CachedFile) {
141+
Log.d("FileTreeCache", "Updating file: $uri")
142+
val uriString = uri.toString()
143+
mutableFiles[uriString] = file
144+
}
145+
146+
override suspend fun write() = withContext(Dispatchers.IO) {
147+
Log.d(TAG, "Writing cache to disk")
148+
val cacheFile = File(context.cacheDir, CACHE_FILENAME)
149+
try {
150+
val fileTreeCache = FileTreeData(
151+
directories = mutableDirectories.toMap(),
152+
files = mutableFiles.toMap()
153+
)
154+
155+
val jsonContent = json.encodeToString(fileTreeCache)
156+
cacheFile.writeText(jsonContent)
157+
Log.d(TAG, "Successfully wrote cache to disk")
158+
} catch (e: Exception) {
159+
Log.e(TAG, "Failed to write cache file", e)
160+
}
161+
Unit
162+
}
163+
}

0 commit comments

Comments
 (0)