Skip to content

Commit fe5553f

Browse files
committed
Backup and restore: add MediaStore backup helper
1 parent a5f2c38 commit fe5553f

File tree

2 files changed

+352
-0
lines changed

2 files changed

+352
-0
lines changed

common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/BackupAndRestoreHelper.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import android.database.Cursor;
88
import android.net.Uri;
99
import android.os.Build.VERSION;
10+
import android.os.Build.VERSION_CODES;
1011
import android.provider.OpenableColumns;
1112
import android.widget.Toast;
1213

@@ -45,6 +46,12 @@ public void exportAppMediaFolder() {
4546
File zipFile = new File(mediaDir, "backup_" + mContext.getPackageName() + ".zip");
4647
ZipHelper2.zipDirectory(dataDir, zipFile);
4748

49+
//if (VERSION.SDK_INT >= 29) {
50+
// MFile file = new MFile(mContext, zipFile.getName());
51+
// file.copyFrom(zipFile);
52+
// //file.getStoredName();
53+
//}
54+
4855
Uri uri = FileProvider.getUriForFile(
4956
mContext,
5057
mContext.getPackageName() + ".update_provider",
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
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

Comments
 (0)