1+ package com.liskovsoft.smartyoutubetv2.common.misc
2+
3+ import android.content.ContentValues
4+ import android.content.Context
5+ import android.net.Uri
6+ import android.os.Environment
7+ import android.provider.MediaStore
8+ import androidx.annotation.RequiresApi
9+ import java.io.File
10+ import java.io.InputStream
11+ import java.io.OutputStream
12+
13+ /* *
14+ * Wrapper around MediaStore API to provide a File-like interface
15+ * for Android 10+ scoped storage.
16+ *
17+ * All files and folders are created under:
18+ * `Documents/<packageName>/...`
19+ *
20+ * Directories are simulated using a hidden `.dir` file marker.
21+ *
22+ * Example usage:
23+ * ```
24+ * val backupsDir = MFile(context, "backups")
25+ * backupsDir.mkdirs()
26+ *
27+ * val backupFile = backupsDir.child("backup.zip")
28+ * backupFile.copyFrom(sourceFile) // copy from java.io.File
29+ *
30+ * println(backupFile.length())
31+ * println(backupFile.lastModified())
32+ *
33+ * val renamedFile = backupFile.child("backup_new.zip")
34+ * backupFile.renameTo(renamedFile)
35+ * renamedFile.setLastModified(System.currentTimeMillis())
36+ *
37+ * val files = backupsDir.listFiles()
38+ * files.forEach { println(it.resolve()) }
39+ * ```
40+ *
41+ * @param context Context used to access ContentResolver
42+ * @param path Relative path to the root directory (app package folder)
43+ */
44+ @RequiresApi(29 )
45+ internal class MFile (
46+ private val context : Context ,
47+ private val path : String // relative to rootDir
48+ ) {
49+ private val rootDir = context.packageName
50+ private var cachedUri: Uri ? = null
51+
52+ private val resolver get() = context.contentResolver
53+
54+ private fun name (): String =
55+ path.substringAfterLast(" /" )
56+
57+ private fun parent (): String =
58+ path.substringBeforeLast(" /" , " " )
59+
60+ private fun relativePath (): String {
61+ val parent = parent()
62+ return if (parent.isEmpty())
63+ Environment .DIRECTORY_DOCUMENTS + " /" + rootDir
64+ else
65+ Environment .DIRECTORY_DOCUMENTS + " /" + rootDir + " /" + parent
66+ }
67+
68+ private fun fullRelativeDir (): String {
69+ return Environment .DIRECTORY_DOCUMENTS + " /" + rootDir + " /" + path
70+ }
71+
72+ private fun findUri (): Uri ? {
73+ if (cachedUri != null ) return cachedUri
74+
75+ val projection = arrayOf(MediaStore .Files .FileColumns ._ID )
76+
77+ val selection =
78+ " ${MediaStore .Files .FileColumns .DISPLAY_NAME } =? AND ${MediaStore .Files .FileColumns .RELATIVE_PATH } =?"
79+
80+ val selectionArgs = arrayOf(
81+ name(),
82+ relativePath() + " /"
83+ )
84+
85+ resolver.query(
86+ MediaStore .Files .getContentUri(" external" ),
87+ projection,
88+ selection,
89+ selectionArgs,
90+ null
91+ )?.use { cursor ->
92+ if (cursor.moveToFirst()) {
93+ val id = cursor.getLong(0 )
94+ cachedUri = Uri .withAppendedPath(
95+ MediaStore .Files .getContentUri(" external" ),
96+ id.toString()
97+ )
98+ return cachedUri
99+ }
100+ }
101+ return null
102+ }
103+
104+ fun exists (): Boolean = findUri() != null
105+
106+ fun isFile (): Boolean = exists()
107+
108+ /* *
109+ * The .dir marker is just a convention we use to simulate directories in MediaStore, because MediaStore doesn’t really have “directory” entries — it only stores files, each with a RELATIVE_PATH.
110+ *
111+ * So if you want a folder to “exist” even if it’s empty, you create a hidden placeholder file called .dir inside that folder.
112+ */
113+ fun isDirectory (): Boolean {
114+ val dirMarker = MFile (context, " $path /.dir" )
115+ if (dirMarker.exists()) return true
116+
117+ val projection = arrayOf(MediaStore .Files .FileColumns ._ID )
118+ val selection =
119+ " ${MediaStore .Files .FileColumns .RELATIVE_PATH } LIKE ?"
120+ val selectionArgs = arrayOf(fullRelativeDir() + " /%" )
121+
122+ resolver.query(
123+ MediaStore .Files .getContentUri(" external" ),
124+ projection,
125+ selection,
126+ selectionArgs,
127+ null
128+ )?.use { cursor ->
129+ return cursor.count > 0
130+ }
131+
132+ return false
133+ }
134+
135+ fun createNewFile (): Boolean {
136+ if (exists()) return false
137+
138+ val values = ContentValues ().apply {
139+ put(MediaStore .Files .FileColumns .DISPLAY_NAME , name())
140+ put(MediaStore .Files .FileColumns .MIME_TYPE , " application/octet-stream" )
141+ put(MediaStore .Files .FileColumns .RELATIVE_PATH , relativePath())
142+ }
143+
144+ val uri = resolver.insert(
145+ MediaStore .Files .getContentUri(" external" ),
146+ values
147+ )
148+
149+ cachedUri = uri
150+ return uri != null
151+ }
152+
153+ fun mkdirs () {
154+ if (path.isEmpty()) return
155+
156+ val parts = path.split(" /" )
157+ var current = " "
158+
159+ for (part in parts) {
160+ if (part.isEmpty()) continue
161+ current = if (current.isEmpty()) part else " $current /$part "
162+ val dummy = MFile (context, " $current /.dir" )
163+ if (! dummy.exists()) {
164+ dummy.createNewFile()
165+ }
166+ }
167+ }
168+
169+ fun delete (): Boolean {
170+ val uri = findUri() ? : return false
171+ val result = resolver.delete(uri, null , null ) > 0
172+ if (result) cachedUri = null
173+ return result
174+ }
175+
176+ fun listFiles (): List <MFile > {
177+ val list = mutableListOf<MFile >()
178+
179+ val projection = arrayOf(
180+ MediaStore .Files .FileColumns .DISPLAY_NAME
181+ )
182+
183+ val selection =
184+ " ${MediaStore .Files .FileColumns .RELATIVE_PATH } =?"
185+
186+ val selectionArgs = arrayOf(
187+ fullRelativeDir() + " /"
188+ )
189+
190+ resolver.query(
191+ MediaStore .Files .getContentUri(" external" ),
192+ projection,
193+ selection,
194+ selectionArgs,
195+ null
196+ )?.use { cursor ->
197+ while (cursor.moveToNext()) {
198+ val fileName = cursor.getString(0 )
199+ if (fileName != " .dir" ) {
200+ list.add(MFile (context, " $path /$fileName " ))
201+ }
202+ }
203+ }
204+
205+ return list
206+ }
207+
208+ fun openInputStream (): InputStream ? {
209+ return findUri()?.let { resolver.openInputStream(it) }
210+ }
211+
212+ fun openOutputStream (): OutputStream ? {
213+ val uri = findUri() ? : run {
214+ createNewFile()
215+ findUri()
216+ } ? : return null
217+
218+ return resolver.openOutputStream(uri)
219+ }
220+
221+ fun copyFrom (src : File ) {
222+ val uri = findUri() ? : run {
223+ createNewFile()
224+ findUri()
225+ }
226+
227+ resolver.openOutputStream(uri!! )?.use { out ->
228+ src.inputStream().use { input ->
229+ input.copyTo(out )
230+ }
231+ }
232+ }
233+
234+ fun copyTo (dest : MFile ) {
235+ val input = openInputStream() ? : return
236+ val output = dest.openOutputStream() ? : return
237+
238+ input.use { inp ->
239+ output.use { out ->
240+ inp.copyTo(out )
241+ }
242+ }
243+ }
244+
245+ fun copyTo (dest : File ) {
246+ val input = openInputStream() ? : return
247+
248+ dest.outputStream().use { out ->
249+ input.use { inp ->
250+ inp.copyTo(out )
251+ }
252+ }
253+ }
254+
255+ /* *
256+ * Create file in the current directory
257+ */
258+ fun child (name : String ): MFile {
259+ return if (path.isEmpty())
260+ MFile (context, name)
261+ else
262+ MFile (context, " $path /$name " )
263+ }
264+
265+ fun resolve (): String {
266+ return Environment .getExternalStoragePublicDirectory(
267+ Environment .DIRECTORY_DOCUMENTS
268+ ).absolutePath + " /" + rootDir + " /" + path
269+ }
270+
271+ /* *
272+ * Returns the full path in external storage if available.
273+ * Note: The file may not exist on disk yet (MediaStore may manage it).
274+ */
275+ fun getAbsolutePath (): String {
276+ val fileName = getStoredName() ? : name()
277+ return Environment .getExternalStoragePublicDirectory(
278+ Environment .DIRECTORY_DOCUMENTS
279+ ).absolutePath + " /" + rootDir + " /" + parent() + " /" + fileName
280+ }
281+
282+ fun renameTo (dest : MFile ): Boolean {
283+ val uri = findUri() ? : return false
284+
285+ val values = ContentValues ().apply {
286+ put(MediaStore .Files .FileColumns .DISPLAY_NAME , dest.name())
287+ put(MediaStore .Files .FileColumns .RELATIVE_PATH , dest.relativePath())
288+ }
289+
290+ val updated = resolver.update(uri, values, null , null ) > 0
291+ if (updated) {
292+ cachedUri = null // invalidate cache
293+ }
294+ return updated
295+ }
296+
297+ fun length (): Long {
298+ val uri = findUri() ? : return 0
299+ val projection = arrayOf(MediaStore .Files .FileColumns .SIZE )
300+
301+ resolver.query(uri, projection, null , null , null )?.use { cursor ->
302+ if (cursor.moveToFirst()) {
303+ return cursor.getLong(0 )
304+ }
305+ }
306+ return 0
307+ }
308+
309+ fun size (): Long = length() // alias
310+
311+ fun lastModified (): Long {
312+ val uri = findUri() ? : return 0
313+ val projection = arrayOf(MediaStore .Files .FileColumns .DATE_MODIFIED )
314+
315+ resolver.query(uri, projection, null , null , null )?.use { cursor ->
316+ if (cursor.moveToFirst()) {
317+ return cursor.getLong(0 ) * 1000 // convert to milliseconds
318+ }
319+ }
320+ return 0
321+ }
322+
323+ fun setLastModified (timeMillis : Long ): Boolean {
324+ val uri = findUri() ? : return false
325+
326+ val values = ContentValues ().apply {
327+ put(MediaStore .Files .FileColumns .DATE_MODIFIED , timeMillis / 1000 ) // seconds
328+ }
329+
330+ val updated = resolver.update(uri, values, null , null ) > 0
331+ return updated
332+ }
333+
334+ /* *
335+ * The real name of the file (if the same created by another app)
336+ */
337+ fun getStoredName (): String? {
338+ val uri = findUri() ? : return null
339+ val projection = arrayOf(MediaStore .Files .FileColumns .DISPLAY_NAME )
340+ resolver.query(uri, projection, null , null , null )?.use { cursor ->
341+ if (cursor.moveToFirst()) return cursor.getString(0 )
342+ }
343+ return null
344+ }
345+ }
0 commit comments