Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement savegame provider to expose internal directory #936

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lemuroid-app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />

<provider
android:name=".app.shared.savesync.SavegameProvider"
android:authorities="${applicationId}.savegameprovider"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
package com.swordfish.lemuroid.app.shared.savesync


import android.content.res.AssetFileDescriptor
import android.database.Cursor
import android.database.MatrixCursor
import android.os.Build
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap
import androidx.annotation.StringRes
import com.swordfish.lemuroid.lib.storage.DirectoriesManager
import java.io.File

/*
This is heavily based on two sources:
1. https://developer.android.com/guide/topics/providers/create-document-provider#kotlin
2. https://github.com/dolphin-emu/dolphin/blob/68fe6779eb8c9a1594cb8975b3e9edbbd428c405/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/DocumentProvider.kt

and to lesser extend this:
3. https://android.googlesource.com/platform/packages/providers/DownloadProvider/+/8ec0057/src/com/android/providers/downloads/DownloadStorageProvider.java

Especially the dolpin-source was extremely helpful with understanding and implementing this feature. It would not have been possible without. Thanks!
*/

class SavegameProvider : DocumentsProvider() {

private lateinit var directoryManager: DirectoriesManager
companion object {
const val ROOT_ID = "internal_data"

private val DEFAULT_ROOT_PROJECTION = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_DOCUMENT_ID
)

private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE
)
}

override fun onCreate(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
directoryManager = DirectoriesManager(requireContext())
} else {
val manager = this.context?.let { DirectoriesManager(it) }
if(manager == null) {
return false
}
directoryManager = manager
}
return true
}


override fun queryRoots(projection: Array<String>?): Cursor {

val result = MatrixCursor(resolveRootProjection(projection))
result.newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID)
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_ID)
add(DocumentsContract.Root.COLUMN_TITLE, getString(com.swordfish.lemuroid.R.string.lemuroid_name))
add(DocumentsContract.Root.COLUMN_ICON, com.swordfish.lemuroid.R.mipmap.lemuroid_launcher)
add(
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.FLAG_SUPPORTS_CREATE or
DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
)
}
return result
}

override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
addIdToCursor(documentId, cursor)
return cursor
}

override fun queryChildDocuments(parentDocumentId: String, projection: Array<String>?, queryArgs: String?): Cursor {
return MatrixCursor(resolveDocumentProjection(projection)).apply {
resolveId(parentDocumentId).listFiles()?.forEach { file ->
addFileToCursor(file, this)
}
}
}

override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor? {
val file = resolveId(documentId)
if (!file.canWrite() && (mode == "rw" || mode == "w" || mode == "a" || mode == "t")) {
throw UnsupportedOperationException("File $documentId is not writeable!")
}
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode))
}

override fun openDocumentThumbnail(documentId: String, sizeHint: android.graphics.Point, signal: CancellationSignal): AssetFileDescriptor {
val descriptor = openDocument(documentId, "r", signal)
return AssetFileDescriptor(descriptor, 0, AssetFileDescriptor.UNKNOWN_LENGTH)
}

override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String {
val parent = resolveId(parentDocumentId)

if(mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
File(parent, displayName).mkdirs()
} else {
File(parent, displayName).createNewFile()
}
return "$parentDocumentId/$displayName"
}

override fun deleteDocument(documentId: String) {
resolveId(documentId).delete()
}

override fun renameDocument(documentId: String, displayName: String): String? {
val file = resolveId(documentId)
val new = File(file.parent, displayName)
file.renameTo(new)
return null
}


/* https://android.googlesource.com/platform/packages/providers/DownloadProvider/+/8ec0057/src/com/android/providers/downloads/DownloadStorageProvider.java */
private fun resolveRootProjection(projection: Array<String>?): Array<String> {
return projection ?: DEFAULT_ROOT_PROJECTION
}

private fun resolveDocumentProjection(projection: Array<String>?): Array<String> {
return projection ?: DEFAULT_DOCUMENT_PROJECTION
}


private fun addIdToCursor(documentId: String, cursor: MatrixCursor) {
addFileToCursor(resolveId(documentId), cursor)
}

private fun addFileToCursor(file: File, cursor: MatrixCursor) {

if(file == directoryManager.getInternalRomsDirectory()) {
return
}

val name = determineName(file)
val flags = determineFlags(file)

cursor.newRow().apply {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, generateId(file))
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getType(file))
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, name)
add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified())
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
add(DocumentsContract.Document.COLUMN_SIZE, file.length())
}
}

private fun determineFlags(file: File): Int {
var flags = 0

if (file.canWrite()) {
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
}

if(file.isDirectory) {
flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
}

return flags
}

private fun determineName(file: File): String {
if (file == directoryManager.getStatesDirectory()) {
return getString(com.swordfish.lemuroid.R.string.documentprovider_folder_override_states)
}
if (file == directoryManager.getSavesDirectory()) {
return getString(com.swordfish.lemuroid.R.string.documentprovider_folder_override_saves)
}
if (file == directoryManager.getInternalRomsDirectory()) {
return getString(com.swordfish.lemuroid.R.string.documentprovider_folder_override_roms)
}
if (file == directoryManager.getStatesPreviewDirectory()) {
return getString(com.swordfish.lemuroid.R.string.documentprovider_folder_override_previews)
}

if (isDirectChild(file, directoryManager.getSavesDirectory())) {
when(file.name) {
"mgba" -> return "Game Boy Advance"
"fbneo" -> return "FinalBurn Neo"
"gb" -> return "Game Boy"
"gbc" -> return "Game Boy Color"
"n64" -> return "Nintendo 64"
"nds" -> return "Nintendo DS"
"nes" -> return "Nintendo Entertainment System"
"snes" -> return "Super Nintendo Entertainment System"
"psx" -> return "Playsation 1"
}
}

return file.name
}

private fun getString(@StringRes id: Int): String {
return context!!.getString(id)
}

private fun isDirectChild(file: File, of: File): Boolean {
of.listFiles()?.forEach {
if(it == file) {
return true
}
}
return false
}

/* Helper */

private fun generateId(file: File): String {
return ROOT_ID + "/" + file.toRelativeString(getRoot())
}

private fun resolveId(id: String): File {
if (id == ROOT_ID) {
return getRoot()
}
val localId = id.removePrefix("$ROOT_ID/")
val file = getRoot().resolve(localId)
return file
}

private fun getRoot(): File = directoryManager.getBaseDir()

private fun getType(file: File): String {
val mime = MimeTypeMap.getSingleton()
var type = "application/octet-stream"

if(file.isDirectory){
type = DocumentsContract.Document.MIME_TYPE_DIR
} else if (mime.hasMimeType(file.extension)) {
type = mime.getMimeTypeFromExtension(file.extension).toString()
}

return type
}
}
4 changes: 4 additions & 0 deletions lemuroid-app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,8 @@
<string name="haptic_feedback_mode_names_press">Press</string>
<string name="haptic_feedback_mode_names_press_release">Press &amp; release</string>

<string name="documentprovider_folder_override_saves">Saves</string>
<string name="documentprovider_folder_override_states">States</string>
<string name="documentprovider_folder_override_roms">Roms</string>
<string name="documentprovider_folder_override_previews">Previews</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,9 @@ class DirectoriesManager(private val appContext: Context) {
File(appContext.getExternalFilesDir(null), "roms").apply {
mkdirs()
}

// why is getExternalFilesDir not nullsafe, while the others are?
fun getBaseDir(): File {
return appContext.getExternalFilesDir(null)!!
}
}