Volume provider

This commit is contained in:
Matéo Duparc 2023-09-06 19:27:41 +02:00
parent 6d04349b2e
commit 79db84f81d
Signed by: hardcoresushi
GPG Key ID: AFE384344A45E13A
35 changed files with 982 additions and 327 deletions

View File

@ -5,7 +5,8 @@ project(DroidFS)
option(GOCRYPTFS "build libgocryptfs" ON)
option(CRYFS "build libcryfs" ON)
add_library(memfile SHARED src/main/native/memfile.cpp)
add_library(memfile SHARED src/main/native/memfile.c)
target_link_libraries(memfile log)
if (GOCRYPTFS)
add_library(gocryptfs SHARED IMPORTED)

View File

@ -3,10 +3,6 @@
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<permission
android:name="${applicationId}.WRITE_TEMPORARY_STORAGE"
android:protectionLevel="signature" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
@ -68,16 +64,21 @@
</receiver>
<provider
android:name=".content_providers.MemoryFileProvider"
android:authorities="${applicationId}.memory_provider"
android:exported="true"
android:writePermission="${applicationId}.WRITE_TEMPORARY_STORAGE" />
android:name=".content_providers.TemporaryFileProvider"
android:authorities="${applicationId}.temporary_provider"
android:exported="true"/>
<provider
android:name=".content_providers.DiskFileProvider"
android:authorities="${applicationId}.disk_provider"
android:authorities="${applicationId}.volume_provider"
android:name=".content_providers.VolumeProvider"
android:exported="true"
android:writePermission="${applicationId}.WRITE_TEMPORARY_STORAGE" />
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>

View File

@ -160,7 +160,7 @@ class ChangePasswordActivity: BaseActivity() {
)
}
if (success) {
if (volumeDatabase.isHashSaved(volume.name)) {
if (volumeDatabase.isHashSaved(volume)) {
volumeDatabase.removeHash(volume)
}
}

View File

@ -0,0 +1,225 @@
package sushi.hardcore.droidfs
import android.app.ActivityManager
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.ParcelFileDescriptor
import android.system.Os
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.Wiper
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
class EncryptedFileProvider(context: Context) {
companion object {
private const val TAG = "EncryptedFileProvider"
fun getTmpFilesDir(context: Context) = File(context.cacheDir, "tmp")
}
private val memoryInfo = ActivityManager.MemoryInfo()
private val isMemFileSupported = Compat.isMemFileSupported()
private val tmpFilesDir by lazy { getTmpFilesDir(context) }
private val handler by lazy { Handler(context.mainLooper) }
init {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(
memoryInfo
)
}
class ExportedDiskFile private constructor(
path: String,
private val file: File,
private val handler: Handler
) : ExportedFile(path) {
companion object {
fun create(path: String, tmpFilesDir: File, handler: Handler): ExportedDiskFile? {
val uuid = UUID.randomUUID().toString()
val file = File(tmpFilesDir, uuid)
return if (file.createNewFile()) {
ExportedDiskFile(path, file, handler)
} else {
null
}
}
}
override fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor {
return if (furtive) {
ParcelFileDescriptor.open(file, mode, handler) {
free()
}
} else {
ParcelFileDescriptor.open(file, mode)
}
}
override fun free() {
Wiper.wipe(file)
}
}
class ExportedMemFile private constructor(path: String, private val file: MemFile) :
ExportedFile(path) {
companion object {
fun create(path: String, size: Long): ExportedMemFile? {
val uuid = UUID.randomUUID().toString()
MemFile.create(uuid, size)?.let {
return ExportedMemFile(path, it)
}
return null
}
}
override fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor {
val fd = if (furtive) {
file.toParcelFileDescriptor()
} else {
file.dup()
}
if (mode and ParcelFileDescriptor.MODE_TRUNCATE != 0) {
Os.ftruncate(fd.fileDescriptor, 0)
} else {
FileInputStream(fd.fileDescriptor).apply {
channel.position(0)
close()
}
}
return fd
}
override fun free() = file.close()
}
abstract class ExportedFile(val path: String) {
var isValid = true
private set
fun invalidate() {
isValid = false
}
/**
* @param furtive If set to true, the file will be deleted when closed
*/
abstract fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor
abstract fun free()
}
fun createFile(
path: String,
size: Long,
): ExportedFile? {
return if (size > memoryInfo.availMem * 0.8) {
ExportedDiskFile.create(
path,
tmpFilesDir,
handler,
)
} else if (isMemFileSupported) {
ExportedMemFile.create(path, size) as ExportedFile
} else {
null
}
}
fun exportFile(
exportedFile: ExportedFile,
encryptedVolume: EncryptedVolume,
): Boolean {
val fd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false).fileDescriptor
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(fd))
}
enum class Error {
SUCCESS,
INVALID_STATE,
WRITE_ACCESS_DENIED,
UNSUPPORTED_APPEND,
UNSUPPORTED_RW,
;
fun log() {
Log.e(
TAG, when (this) {
SUCCESS -> "No error"
INVALID_STATE -> "Read after write is not supported"
WRITE_ACCESS_DENIED -> "Write access unauthorized"
UNSUPPORTED_APPEND -> "Appending is not supported"
UNSUPPORTED_RW -> "Read-write access requires Android 11 or later"
}
)
}
}
/**
* @param furtive If set to true, the file will be deleted when closed
*/
fun openFile(
file: ExportedFile,
mode: String,
encryptedVolume: EncryptedVolume,
volumeScope: CoroutineScope,
furtive: Boolean,
allowWrites: Boolean,
): Pair<ParcelFileDescriptor?, Error> {
val mode = ParcelFileDescriptor.parseMode(mode)
return if (mode and ParcelFileDescriptor.MODE_READ_ONLY != 0) {
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
Pair(file.open(mode, furtive), Error.SUCCESS)
} else {
if (!allowWrites) {
return Pair(null, Error.WRITE_ACCESS_DENIED)
}
fun import(input: InputStream): Boolean {
return if (encryptedVolume.importFile(input, file.path)) {
true
} else {
Log.e(TAG, "Failed to import file")
false
}
}
if (mode and ParcelFileDescriptor.MODE_WRITE_ONLY != 0) {
if (mode and ParcelFileDescriptor.MODE_APPEND != 0) {
return Pair(null, Error.UNSUPPORTED_APPEND)
}
if (mode and ParcelFileDescriptor.MODE_TRUNCATE == 0) {
Log.w(TAG, "Truncating file despite not being requested")
}
val pipe = ParcelFileDescriptor.createReliablePipe()
val input = FileInputStream(pipe[0].fileDescriptor)
volumeScope.launch {
if (import(input)) {
file.invalidate()
}
}
Pair(pipe[1], Error.SUCCESS)
} else { // read-write
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val fd = file.open(mode, false)
Pair(ParcelFileDescriptor.wrap(fd, handler) { e ->
if (e == null) {
import(FileInputStream(fd.fileDescriptor))
if (furtive) {
file.free()
}
}
}, Error.SUCCESS)
} else {
Pair(null, Error.UNSUPPORTED_RW)
}
}
}
}
}

View File

@ -4,66 +4,48 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import sushi.hardcore.droidfs.content_providers.DiskFileProvider
import sushi.hardcore.droidfs.content_providers.MemoryFileProvider
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Version
import java.io.File
class FileShare(private val encryptedVolume: EncryptedVolume, private val context: Context) {
class FileShare(context: Context) {
companion object {
private const val content_type_all = "*/*"
fun getContentType(filename: String, previousContentType: String?): String {
if (content_type_all != previousContentType) {
private const val CONTENT_TYPE_ANY = "*/*"
private fun getContentType(filename: String, previousContentType: String?): String {
if (CONTENT_TYPE_ANY != previousContentType) {
var contentType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(File(filename).extension)
if (contentType == null) {
contentType = content_type_all
contentType = CONTENT_TYPE_ANY
}
if (previousContentType == null) {
return contentType
} else if (previousContentType != contentType) {
return content_type_all
return CONTENT_TYPE_ANY
}
}
return previousContentType
}
}
private val fileProvider: TemporaryFileProvider<*>
private val usfSafWrite = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("usf_saf_write", false)
init {
var provider: MemoryFileProvider? = null
System.getProperty("os.version")?.let {
if (Version(it) >= Version("3.17")) {
provider = MemoryFileProvider()
}
}
fileProvider = provider ?: DiskFileProvider()
private fun exportFile(exportedFile: EncryptedFileProvider.ExportedFile, size: Long, volumeId: Int, previousContentType: String? = null): Pair<Uri, String>? {
val uri = TemporaryFileProvider.instance.exportFile(exportedFile, size, volumeId) ?: return null
return Pair(uri, getContentType(File(exportedFile.path).name, previousContentType))
}
private fun exportFile(path: String, size: Long, previousContentType: String? = null): Pair<Uri?, String?> {
val fileName = File(path).name
val uri = fileProvider.newFile(fileName, size)
if (uri != null) {
if (encryptedVolume.exportFile(context, path, uri)) {
return Pair(uri, getContentType(fileName, previousContentType))
}
}
return Pair(null, null)
}
fun share(files: List<Pair<String, Long>>): Pair<Intent?, String?> {
fun share(files: List<Pair<String, Long>>, volumeId: Int): Pair<Intent?, Int?> {
var contentType: String? = null
val uris = ArrayList<Uri>(files.size)
for ((path, size) in files) {
val result = exportFile(path, size, contentType)
contentType = if (result.first == null) {
return Pair(null, path)
val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
?: return Pair(null, R.string.export_failed_create)
val result = exportFile(exportedFile, size, volumeId, contentType)
contentType = if (result == null) {
return Pair(null, R.string.export_failed_export)
} else {
uris.add(result.first!!)
uris.add(result.first)
result.second
}
}
@ -79,15 +61,18 @@ class FileShare(private val encryptedVolume: EncryptedVolume, private val contex
}, null)
}
fun openWith(path: String, size: Long): Intent? {
val result = exportFile(path, size)
return if (result.first != null) {
Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
setDataAndType(result.first, result.second)
}
fun openWith(exportedFile: EncryptedFileProvider.ExportedFile, size: Long, volumeId: Int): Pair<Intent?, Int?> {
val result = exportFile(exportedFile, size, volumeId)
return if (result == null) {
Pair(null, R.string.export_failed_export)
} else {
null
Pair(Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (usfSafWrite) {
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
setDataAndType(result.first, result.second)
}, null)
}
}
}

View File

@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
import sushi.hardcore.droidfs.adapters.VolumeAdapter
import sushi.hardcore.droidfs.add_volume.AddVolumeActivity
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.databinding.ActivityMainBinding
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter
@ -195,7 +196,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
private fun removeVolume(volume: VolumeData) {
volumeManager.getVolumeId(volume)?.let { volumeManager.closeVolume(it) }
volumeDatabase.removeVolume(volume.name)
volumeDatabase.removeVolume(volume)
}
private fun removeVolumes(volumes: List<VolumeData>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
@ -324,7 +325,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
DocumentFile.fromFile(File(volume.name)),
DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
) {
VolumeData(volume.shortName, true, volume.type, volume.encryptedHash, volume.iv)
VolumeData(
VolumeData.newUuid(),
volume.shortName,
true,
volume.type,
volume.encryptedHash,
volume.iv
)
}
}
}
@ -398,6 +406,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
if (path == null) null
else VolumeData(
VolumeData.newUuid(),
PathUtils.pathJoin(path, name),
false,
volume.type,
@ -466,7 +475,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
DocumentFile.fromFile(srcPath).renameTo(newName)
}
if (success) {
volumeDatabase.renameVolume(volume.name, newDBName)
volumeDatabase.renameVolume(volume, newDBName)
VolumeProvider.notifyRootsChanged(this)
unselect(position)
if (volume.name == volumeOpener.defaultVolumeName) {
with (sharedPrefs.edit()) {

View File

@ -1,6 +1,7 @@
package sushi.hardcore.droidfs
import android.os.ParcelFileDescriptor
import android.system.Os
class MemFile private constructor(private val fd: Int) {
companion object {
@ -15,8 +16,7 @@ class MemFile private constructor(private val fd: Int) {
}
}
private external fun close(fd: Int)
fun getParcelFileDescriptor(): ParcelFileDescriptor = ParcelFileDescriptor.fromFd(fd)
fun close() = close(fd)
fun dup(): ParcelFileDescriptor = ParcelFileDescriptor.fromFd(fd)
fun toParcelFileDescriptor(): ParcelFileDescriptor = ParcelFileDescriptor.adoptFd(fd)
fun close() = Os.close(ParcelFileDescriptor.adoptFd(fd).fileDescriptor)
}

View File

@ -7,8 +7,16 @@ import android.os.Bundle
import android.text.InputType
import android.view.MenuItem
import android.widget.Toast
import androidx.preference.*
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreference
import androidx.preference.SwitchPreferenceCompat
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -150,6 +158,47 @@ class SettingsActivity : BaseActivity() {
true
}
}
val switchKeepOpen = findPreference<SwitchPreference>("usf_keep_open")!!
val switchExternalOpen = findPreference<SwitchPreference>("usf_open")!!
val switchExpose = findPreference<SwitchPreference>("usf_expose")!!
val switchSafWrite = findPreference<SwitchPreference>("usf_saf_write")!!
fun updateView(usfOpen: Boolean? = null, usfKeepOpen: Boolean? = null, usfExpose: Boolean? = null) {
val usfKeepOpen = usfKeepOpen ?: switchKeepOpen.isChecked
switchExpose.isEnabled = usfKeepOpen
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || (usfKeepOpen && usfExpose ?: switchExpose.isChecked)
}
updateView()
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
updateView(usfKeepOpen = checked as Boolean)
true
}
switchExternalOpen.setOnPreferenceChangeListener { _, checked ->
updateView(usfOpen = checked as Boolean)
true
}
switchExpose.setOnPreferenceChangeListener { _, checked ->
if (checked as Boolean) {
if (!Compat.isMemFileSupported()) {
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
.setTitle(R.string.error)
.setMessage("Your current kernel does not support memfd_create(). This feature requires a minimum kernel version of ${Compat.MEMFD_CREATE_MINIMUM_KERNEL_VERSION}.")
.setPositiveButton(R.string.ok, null)
.show()
return@setOnPreferenceChangeListener false
}
}
VolumeProvider.usfExpose = checked
updateView(usfExpose = checked)
VolumeProvider.notifyRootsChanged(requireContext())
true
}
switchSafWrite.setOnPreferenceChangeListener { _, checked ->
VolumeProvider.usfSafWrite = checked as Boolean
TemporaryFileProvider.usfSafWrite = checked
true
}
}
}
}

View File

@ -8,10 +8,19 @@ import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
import java.io.FileInputStream
import java.util.UUID
class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte, var encryptedHash: ByteArray? = null, var iv: ByteArray? = null): Parcelable {
class VolumeData(
val uuid: String,
val name: String,
val isHidden: Boolean = false,
val type: Byte,
var encryptedHash: ByteArray? = null,
var iv: ByteArray? = null
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!,
parcel.readByte() != 0.toByte(),
parcel.readByte(),
@ -23,12 +32,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
File(name).name
}
fun getFullPath(filesDir: String): String {
return if (isHidden)
getHiddenVolumeFullPath(filesDir, name)
else
name
}
fun getFullPath(filesDir: String) = getFullPath(name, isHidden, filesDir)
fun canRead(filesDir: String): Boolean {
val volumePath = getFullPath(filesDir)
@ -62,6 +66,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
override fun writeToParcel(dest: Parcel, flags: Int) {
with (dest) {
writeString(uuid)
writeString(name)
writeByte(if (isHidden) 1 else 0)
writeByte(type)
@ -74,12 +79,10 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
if (other !is VolumeData) {
return false
}
return other.name == name && other.isHidden == isHidden
return other.uuid == uuid
}
override fun hashCode(): Int {
return name.hashCode()+isHidden.hashCode()
}
override fun hashCode() = uuid.hashCode()
companion object {
const val VOLUMES_DIRECTORY = "volumes"
@ -90,8 +93,17 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
}
fun newUuid(): String = UUID.randomUUID().toString()
fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name)
}
fun getFullPath(name: String, isHidden: Boolean, filesDir: String): String {
return if (isHidden)
getHiddenVolumeFullPath(filesDir, name)
else
name
}
}
}

View File

@ -9,47 +9,40 @@ import android.util.Log
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
import java.util.UUID
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 5) {
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 6) {
companion object {
const val TAG = "VolumeDatabase"
const val TABLE_NAME = "Volumes"
const val COLUMN_NAME = "name"
const val COLUMN_HIDDEN = "hidden"
const val COLUMN_TYPE = "type"
const val COLUMN_HASH = "hash"
const val COLUMN_IV = "iv"
private fun contentValuesFromVolume(volume: VolumeData): ContentValues {
val contentValues = ContentValues()
contentValues.put(COLUMN_NAME, volume.name)
contentValues.put(COLUMN_HIDDEN, volume.isHidden)
contentValues.put(COLUMN_TYPE, byteArrayOf(volume.type))
contentValues.put(COLUMN_HASH, volume.encryptedHash)
contentValues.put(COLUMN_IV, volume.iv)
return contentValues
}
private const val TAG = "VolumeDatabase"
private const val TABLE_NAME = "Volumes"
private const val COLUMN_UUID = "uuid"
private const val COLUMN_NAME = "name"
private const val COLUMN_HIDDEN = "hidden"
private const val COLUMN_TYPE = "type"
private const val COLUMN_HASH = "hash"
private const val COLUMN_IV = "iv"
}
override fun onCreate(db: SQLiteDatabase) {
private fun createTable(db: SQLiteDatabase) =
db.execSQL(
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
"$COLUMN_NAME TEXT PRIMARY KEY," +
"$COLUMN_HIDDEN SHORT," +
"$COLUMN_TYPE BLOB," +
"$COLUMN_HASH BLOB," +
"$COLUMN_IV BLOB" +
");"
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
"$COLUMN_UUID TEXT PRIMARY KEY," +
"$COLUMN_NAME TEXT," +
"$COLUMN_HIDDEN SHORT," +
"$COLUMN_TYPE BLOB," +
"$COLUMN_HASH BLOB," +
"$COLUMN_IV BLOB" +
");"
)
override fun onCreate(db: SQLiteDatabase) {
createTable(db)
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()
}
private fun getNewVolumePath(volumeName: String): File {
return File(
VolumeData(
volumeName,
true,
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE
).getFullPath(context.filesDir.path)
VolumeData.getFullPath(volumeName, true, context.filesDir.path)
).canonicalFile
}
@ -101,10 +94,29 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
}
}
}
if (oldVersion < 6) {
val volumeCount = db.rawQuery("SELECT COUNT(*) FROM $TABLE_NAME", null).let { cursor ->
cursor.moveToNext()
cursor.getInt(0).also {
cursor.close()
}
}
db.execSQL("ALTER TABLE $TABLE_NAME RENAME TO OLD;")
createTable(db)
val uuids = (0 until volumeCount).joinToString(", ") { "('${VolumeData.newUuid()}')" }
val baseColumns = "$COLUMN_NAME, $COLUMN_HIDDEN, $COLUMN_TYPE, $COLUMN_HASH, $COLUMN_IV"
// add uuids to old data
db.execSQL("INSERT INTO $TABLE_NAME " +
"SELECT uuid, $baseColumns FROM " +
"(SELECT $baseColumns, ROW_NUMBER() OVER () i FROM OLD) NATURAL JOIN " +
"(SELECT column1 uuid, ROW_NUMBER() OVER () i FROM (VALUES $uuids));")
db.execSQL("DROP TABLE OLD;")
}
}
private fun extractVolumeData(cursor: Cursor): VolumeData {
return VolumeData(
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)),
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(),
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE))[0],
@ -142,7 +154,14 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
fun saveVolume(volume: VolumeData): Boolean {
if (!isVolumeSaved(volume.name, volume.isHidden)) {
return (writableDatabase.insert(TABLE_NAME, null, contentValuesFromVolume(volume)) >= 0.toLong())
return (writableDatabase.insert(TABLE_NAME, null, ContentValues().apply {
put(COLUMN_UUID, volume.uuid)
put(COLUMN_NAME, volume.name)
put(COLUMN_HIDDEN, volume.isHidden)
put(COLUMN_TYPE, byteArrayOf(volume.type))
put(COLUMN_HASH, volume.encryptedHash)
put(COLUMN_IV, volume.iv)
}) == 1.toLong())
}
return false
}
@ -157,8 +176,8 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
return list
}
fun isHashSaved(volumeName: String): Boolean {
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME, COLUMN_HASH), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null)
fun isHashSaved(volume: VolumeData): Boolean {
val cursor = readableDatabase.rawQuery("SELECT $COLUMN_HASH FROM $TABLE_NAME WHERE $COLUMN_UUID=?", arrayOf(volume.uuid))
var isHashSaved = false
if (cursor.moveToNext()) {
if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) {
@ -170,32 +189,33 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
}
fun addHash(volume: VolumeData): Boolean {
return writableDatabase.update(TABLE_NAME, contentValuesFromVolume(volume), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
return writableDatabase.update(TABLE_NAME, ContentValues().apply {
put(COLUMN_HASH, volume.encryptedHash)
put(COLUMN_IV, volume.iv)
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
}
fun removeHash(volume: VolumeData): Boolean {
return writableDatabase.update(
TABLE_NAME, contentValuesFromVolume(
VolumeData(
volume.name,
volume.isHidden,
volume.type,
null,
null
)
), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
}
fun renameVolume(oldName: String, newName: String): Boolean {
return writableDatabase.update(TABLE_NAME,
TABLE_NAME,
ContentValues().apply {
put(COLUMN_NAME, newName)
},
"$COLUMN_NAME=?",arrayOf(oldName)
put(COLUMN_HASH, null as ByteArray?)
put(COLUMN_IV, null as ByteArray?)
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)
) > 0
}
fun removeVolume(volumeName: String): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volumeName)) > 0
fun renameVolume(volume: VolumeData, newName: String): Boolean {
return writableDatabase.update(
TABLE_NAME,
ContentValues().apply {
put(COLUMN_NAME, newName)
},
"$COLUMN_UUID=?", arrayOf(volume.uuid)
) > 0
}
fun removeVolume(volume: VolumeData): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
}
}

View File

@ -1,12 +1,14 @@
package sushi.hardcore.droidfs
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
class VolumeManager {
class VolumeManager(private val context: Context) {
private var id = 0
private val volumes = HashMap<Int, EncryptedVolume>()
private val volumesData = HashMap<VolumeData, Int>()
@ -15,6 +17,7 @@ class VolumeManager {
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
volumes[id] = volume
volumesData[data] = id
VolumeProvider.notifyRootsChanged(context)
return id++
}
@ -30,6 +33,10 @@ class VolumeManager {
return volumes[id]
}
fun listVolumes(): List<Pair<Int, VolumeData>> {
return volumesData.map { (data, id) -> Pair(id, data) }
}
fun getCoroutineScope(volumeId: Int): CoroutineScope {
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
}
@ -41,6 +48,7 @@ class VolumeManager {
volumesData.filter { it.value == id }.forEach {
volumesData.remove(it.key)
}
VolumeProvider.notifyRootsChanged(context)
}
}
@ -51,5 +59,6 @@ class VolumeManager {
}
volumes.clear()
volumesData.clear()
VolumeProvider.notifyRootsChanged(context)
}
}

View File

@ -6,8 +6,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.content_providers.MemoryFileProvider
import sushi.hardcore.droidfs.content_providers.DiskFileProvider
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
companion object {
@ -21,8 +20,9 @@ class VolumeManagerApp : Application(), DefaultLifecycleObserver {
}
}
private var usfKeepOpen = false
var isExporting = false
var isStartingExternalApp = false
val volumeManager = VolumeManager()
val volumeManager = VolumeManager(this)
override fun onCreate() {
super<Application>.onCreate()
@ -46,8 +46,9 @@ class VolumeManagerApp : Application(), DefaultLifecycleObserver {
if (!usfKeepOpen) {
volumeManager.closeAll()
}
DiskFileProvider.wipe()
MemoryFileProvider.wipe()
if (!usfKeepOpen || !isExporting) {
TemporaryFileProvider.instance.wipe()
}
}
}
}

View File

@ -211,11 +211,11 @@ class CreateVolumeFragment: Fragment() {
.show()
} else {
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath
val volume = VolumeData(volumeName, isHiddenVolume, result)
val volume = VolumeData(VolumeData.newUuid(), volumeName, isHiddenVolume, result)
var isVolumeSaved = false
volumeDatabase.apply {
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
removeVolume(volumeName)
removeVolume(volume)
if (rememberVolume) {
isVolumeSaved = saveVolume(volume)
}

View File

@ -350,7 +350,7 @@ class SelectPathFragment: Fragment() {
}
private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) {
val volumeData = VolumeData(volumeName, isHidden, volumeType)
val volumeData = VolumeData(VolumeData.newUuid(), volumeName, isHidden, volumeType)
if (binding.switchRemember.isChecked) {
volumeDatabase.saveVolume(volumeData)
}

View File

@ -1,68 +0,0 @@
package sushi.hardcore.droidfs.content_providers
import android.net.Uri
import android.os.ParcelFileDescriptor
import sushi.hardcore.droidfs.BuildConfig
import sushi.hardcore.droidfs.util.Wiper
import java.io.File
import java.util.UUID
class DiskFileProvider: TemporaryFileProvider<File>() {
companion object {
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".disk_provider"
private val CONTENT_URI: Uri = Uri.parse("content://$AUTHORITY")
const val TEMPORARY_FILES_DIR_NAME = "temp"
private lateinit var tempFilesDir: File
private var files = HashMap<Uri, TemporaryFileProvider<File>.SharedFile>()
fun wipe() {
for (i in files.values) {
Wiper.wipe(i.file)
}
files.clear()
tempFilesDir.listFiles()?.let {
for (file in it) {
Wiper.wipe(file)
}
}
}
}
override fun onCreate(): Boolean {
context?.let {
tempFilesDir = File(it.cacheDir, TEMPORARY_FILES_DIR_NAME)
return tempFilesDir.mkdirs()
}
return false
}
override fun getFile(uri: Uri): SharedFile? = files[uri]
override fun newFile(name: String, size: Long): Uri? {
val uuid = UUID.randomUUID().toString()
val file = File(tempFilesDir, uuid)
return if (file.createNewFile()) {
Uri.withAppendedPath(CONTENT_URI, uuid).also {
files[it] = SharedFile(name, size, file)
}
} else {
null
}
}
override fun delete(uri: Uri, givenSelection: String?, givenSelectionArgs: Array<String>?): Int {
return if (files.remove(uri)?.file?.also { Wiper.wipe(it) } == null) 0 else 1
}
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
return if (("w" in mode && callingPackage == BuildConfig.APPLICATION_ID) || "w" !in mode) {
files[uri]?.file?.let {
return ParcelFileDescriptor.open(it, ParcelFileDescriptor.parseMode(mode))
}
} else {
throw SecurityException("Read-only access")
}
}
}

View File

@ -1,48 +0,0 @@
package sushi.hardcore.droidfs.content_providers
import android.net.Uri
import android.os.ParcelFileDescriptor
import sushi.hardcore.droidfs.BuildConfig
import sushi.hardcore.droidfs.MemFile
import java.io.FileInputStream
import java.util.UUID
class MemoryFileProvider: TemporaryFileProvider<MemFile>() {
companion object {
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".memory_provider"
private val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")
private var files = HashMap<Uri, TemporaryFileProvider<MemFile>.SharedFile>()
fun wipe() {
for (i in files.values) {
i.file.close()
}
files.clear()
}
}
override fun onCreate(): Boolean = true
override fun getFile(uri: Uri): SharedFile? = files[uri]
override fun newFile(name: String, size: Long): Uri? {
val uuid = UUID.randomUUID().toString()
return Uri.withAppendedPath(BASE_URI, uuid).also {
files[it] = SharedFile(name, size, MemFile.create(uuid, size) ?: return null)
}
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
return if (files.remove(uri)?.file?.close() == null) 0 else 1
}
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
return files[uri]?.file?.getParcelFileDescriptor()?.also {
FileInputStream(it.fileDescriptor).apply {
channel.position(0)
close()
}
}
}
}

View File

@ -2,23 +2,87 @@ package sushi.hardcore.droidfs.content_providers
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Intent
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.BuildConfig
import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.util.Wiper
import java.io.File
import java.util.UUID
abstract class TemporaryFileProvider<T>: ContentProvider() {
protected inner class SharedFile(val name: String, val size: Long, val file: T)
class TemporaryFileProvider : ContentProvider() {
private inner class ProvidedFile(
val file: EncryptedFileProvider.ExportedFile,
val size: Long,
val volumeId: Int
)
protected abstract fun getFile(uri: Uri): SharedFile?
abstract fun newFile(name: String, size: Long): Uri?
companion object {
private const val TAG = "TemporaryFileProvider"
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".temporary_provider"
private val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
val file = getFile(uri) ?: return null
lateinit var instance: TemporaryFileProvider
private set
var usfSafWrite = false
}
private lateinit var volumeManager: VolumeManager
lateinit var encryptedFileProvider: EncryptedFileProvider
private val files = HashMap<Uri, ProvidedFile>()
override fun onCreate(): Boolean {
return context?.let {
volumeManager = (it.applicationContext as VolumeManagerApp).volumeManager
usfSafWrite =
PreferenceManager.getDefaultSharedPreferences(it).getBoolean("usf_saf_write", false)
encryptedFileProvider = EncryptedFileProvider(it)
instance = this
val tmpFilesDir = EncryptedFileProvider.getTmpFilesDir(it)
val success = tmpFilesDir.mkdirs()
// wipe any additional files not previously deleted
GlobalScope.launch(Dispatchers.IO) {
tmpFilesDir.listFiles()?.onEach { f -> Wiper.wipe(f) }
}
success
} ?: false
}
fun exportFile(
exportedFile: EncryptedFileProvider.ExportedFile,
size: Long,
volumeId: Int
): Uri? {
if (!encryptedFileProvider.exportFile(exportedFile, volumeManager.getVolume(volumeId)!!)) {
return null
}
return Uri.withAppendedPath(BASE_URI, UUID.randomUUID().toString()).also {
files[it] = ProvidedFile(exportedFile, size, volumeId)
}
}
override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor? {
val file = files[uri] ?: return null
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), 1).apply {
addRow(arrayOf(file.name, file.size))
addRow(arrayOf(File(file.file.path).name, file.size))
}
}
@ -26,11 +90,58 @@ abstract class TemporaryFileProvider<T>: ContentProvider() {
throw UnsupportedOperationException("Operation not supported")
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<String>?
): Int {
throw UnsupportedOperationException("Operation not supported")
}
override fun getType(uri: Uri): String = getFile(uri)?.name?.let {
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
return if (files.remove(uri)?.file?.also { it.free() } == null) 0 else 1
}
override fun getType(uri: Uri): String = files[uri]?.file?.path?.let {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(it).extension)
} ?: "application/octet-stream"
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
files[uri]?.let { file ->
val encryptedVolume = volumeManager.getVolume(file.volumeId) ?: run {
Log.e(TAG, "Volume closed for $uri")
return null
}
val result = encryptedFileProvider.openFile(
file.file,
mode,
encryptedVolume,
volumeManager.getCoroutineScope(file.volumeId),
false,
usfSafWrite,
)
when (result.second) {
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(
TAG,
"Unauthorized write access requested from $callingPackage to $uri"
)
else -> result.second.log()
}
}
return null
}
// this must not be cancelled
fun wipe() = GlobalScope.launch(Dispatchers.IO) {
context!!.revokeUriPermission(BASE_URI, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
synchronized(this@TemporaryFileProvider) {
for (i in files.values) {
i.file.free()
}
files.clear()
}
}
}

View File

@ -0,0 +1,286 @@
package sushi.hardcore.droidfs.content_providers
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.BuildConfig
import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
class VolumeProvider: DocumentsProvider() {
companion object {
private const val TAG = "DocumentsProvider"
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".volume_provider"
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 = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
)
var usfExpose = false
var usfSafWrite = false
fun notifyRootsChanged(context: Context) {
context.contentResolver.notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null)
}
}
private lateinit var volumeManager: VolumeManager
private val volumes = HashMap<String, Pair<Int, VolumeData>>()
private lateinit var encryptedFileProvider: EncryptedFileProvider
override fun onCreate(): Boolean {
val context = (context ?: return false)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
usfExpose = sharedPreferences.getBoolean("usf_expose", false)
usfSafWrite = sharedPreferences.getBoolean("usf_saf_write", false)
volumeManager = (context.applicationContext as VolumeManagerApp).volumeManager
encryptedFileProvider = EncryptedFileProvider(context)
return true
}
override fun queryRoots(projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
if (!usfExpose) return cursor
volumes.clear()
for (volume in volumeManager.listVolumes()) {
var flags = DocumentsContract.Root.FLAG_LOCAL_ONLY or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
if (usfSafWrite && volume.second.canWrite(context!!.filesDir.path)) {
flags = flags or DocumentsContract.Root.FLAG_SUPPORTS_CREATE
}
cursor.newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, volume.second.name)
add(DocumentsContract.Root.COLUMN_FLAGS, flags)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.icon_document_provider)
add(DocumentsContract.Root.COLUMN_TITLE, volume.second.name)
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, volume.second.uuid)
}
volumes[volume.second.uuid] = volume
}
return cursor
}
internal data class DocumentData(
val rootId: String,
val volumeId: Int,
val volumeData: VolumeData,
val encryptedVolume: EncryptedVolume,
val path: String
) {
fun child(childPath: String) = DocumentData(rootId, volumeId, volumeData, encryptedVolume, childPath)
}
private fun parseDocumentId(documentId: String): DocumentData? {
val splits = documentId.split("/", limit = 2)
if (splits.size > 2) {
return null
} else {
volumes[splits[0]]?.let {
val encryptedVolume = volumeManager.getVolume(it.first) ?: return null
val path = "/"+if (splits.size == 2) {
splits[1]
} else {
""
}
return DocumentData(splits[0], it.first, it.second, encryptedVolume, path)
}
}
return null
}
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
if (!usfExpose) return false
val parent = parseDocumentId(parentDocumentId) ?: return false
val child = parseDocumentId(documentId) ?: return false
return parent.rootId == child.rootId && PathUtils.isChildOf(child.path, parent.path)
}
private fun addDocumentRow(cursor: MatrixCursor, volumeData: VolumeData, documentId: String, name: String, stat: Stat) {
val isDirectory = stat.type == Stat.S_IFDIR
var flags = 0
if (usfSafWrite && volumeData.canWrite(context!!.filesDir.path)) {
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
if (isDirectory) {
flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
} else if (stat.type == Stat.S_IFREG) {
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE
}
}
val mimeType = if (isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
} else {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(name).extension)
?: "application/octet-stream"
}
cursor.newRow().apply {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId)
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, name)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType)
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
add(DocumentsContract.Document.COLUMN_SIZE, stat.size)
add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, stat.mTime)
}
}
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
if (!usfExpose) return cursor
val document = parseDocumentId(documentId) ?: return cursor
document.encryptedVolume.getAttr(document.path)?.let { stat ->
val name = if (document.path == "/") {
document.volumeData.shortName
} else {
File(document.path).name
}
addDocumentRow(cursor, document.volumeData, documentId, name, stat)
}
return cursor
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?
): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
if (!usfExpose) return cursor
val document = parseDocumentId(parentDocumentId) ?: return cursor
document.encryptedVolume.readDir(document.path)?.let { content ->
for (i in content) {
if (i.isParentFolder) continue
addDocumentRow(cursor, document.volumeData, document.rootId+i.fullPath, i.name, i.stat)
}
}
return cursor
}
class LazyExportedFile(
private val encryptedFileProvider: EncryptedFileProvider,
private val encryptedVolume: EncryptedVolume,
path: String,
) : EncryptedFileProvider.ExportedFile(path) {
private val exportedFile: EncryptedFileProvider.ExportedFile by lazy {
val size = encryptedVolume.getAttr(path)?.size ?: run {
Log.e(TAG, "stat() failed")
throw RuntimeException("stat() failed")
}
val exportedFile = encryptedFileProvider.createFile(path, size) ?: run {
Log.e(TAG, "Can't create exported file")
throw RuntimeException("Can't create exported file")
}
if (!encryptedFileProvider.exportFile(exportedFile, encryptedVolume)) {
Log.e(TAG, "File export failed")
throw RuntimeException("File export failed")
}
exportedFile
}
override fun open(mode: Int, furtive: Boolean) = exportedFile.open(mode, furtive)
override fun free() = exportedFile.free()
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?
): ParcelFileDescriptor? {
if (!usfExpose) return null
val document = parseDocumentId(documentId) ?: return null
val lazyExportedFile = LazyExportedFile(encryptedFileProvider, document.encryptedVolume, document.path)
val result = encryptedFileProvider.openFile(
lazyExportedFile,
mode,
document.encryptedVolume,
volumeManager.getCoroutineScope(document.volumeId),
true,
usfSafWrite,
)
when (result.second) {
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(TAG, "Unauthorized write access requested from $callingPackage")
else -> result.second.log()
}
return null
}
override fun createDocument(
parentDocumentId: String,
mimeType: String?,
displayName: String
): String? {
if (!usfExpose || !usfSafWrite) return null
val document = parseDocumentId(parentDocumentId) ?: return null
val newFile = PathUtils.pathJoin(document.path, displayName)
val f = document.encryptedVolume.openFileWriteMode(newFile)
return if (f == -1L) {
Log.e(TAG, "Failed to create file: $document")
null
} else {
document.encryptedVolume.closeFile(f)
document.rootId+"/"+newFile
}
}
override fun deleteDocument(documentId: String) {
if (!usfExpose || !usfSafWrite) return
fun recursiveRemoveDirectory(document: DocumentData) {
document.encryptedVolume.readDir(document.path)?.forEach { e ->
val childPath = PathUtils.pathJoin(document.path, e.name)
if (e.isDirectory) {
recursiveRemoveDirectory(document.child(childPath))
} else {
document.encryptedVolume.deleteFile(childPath)
}
revokeDocumentPermission(document.rootId+childPath)
}
document.encryptedVolume.rmdir(document.path)
}
val document = parseDocumentId(documentId) ?: return
document.encryptedVolume.getAttr(document.path)?.let { stat ->
if (stat.type == Stat.S_IFDIR) {
recursiveRemoveDirectory(document)
} else {
document.encryptedVolume.deleteFile(document.path)
}
}
}
override fun renameDocument(documentId: String, displayName: String): String {
if (!usfExpose || !usfSafWrite) return documentId
val document = parseDocumentId(documentId) ?: return documentId
val newPath = PathUtils.pathJoin(PathUtils.getParentPath(document.path), displayName)
return if (document.encryptedVolume.rename(document.path, newPath)) {
document.rootId+newPath
} else {
documentId
}
}
}

View File

@ -29,6 +29,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.FileShare
import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.LoadingTask
@ -36,8 +37,7 @@ import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
import sushi.hardcore.droidfs.content_providers.DiskFileProvider
import sushi.hardcore.droidfs.content_providers.MemoryFileProvider
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.file_operations.TaskResult
@ -84,9 +84,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
private lateinit var numberOfFilesText: TextView
private lateinit var numberOfFoldersText: TextView
private lateinit var totalSizeText: TextView
protected val fileShare by lazy {
FileShare(encryptedVolume, this)
}
protected val fileShare by lazy { FileShare(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -204,22 +202,38 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
startActivity(intent)
}
protected fun onExportFailed(errorResId: Int) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.tmp_export_failed, getString(errorResId)))
.setPositiveButton(R.string.ok, null)
.show()
}
private fun openWithExternalApp(path: String, size: Long) {
app.isStartingExternalApp = true
object : LoadingTask<Intent?>(this, theme, R.string.loading_msg_export) {
override suspend fun doTask(): Intent? {
return fileShare.openWith(path, size)
app.isExporting = true
val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
if (exportedFile == null) {
onExportFailed(R.string.export_failed_create)
return
}
val msg = when (exportedFile) {
is EncryptedFileProvider.ExportedMemFile -> R.string.export_mem
is EncryptedFileProvider.ExportedDiskFile -> R.string.export_disk
else -> R.string.loading_msg_export
}
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, msg) {
override suspend fun doTask(): Pair<Intent?, Int?> {
return fileShare.openWith(exportedFile, size, volumeId)
}
}.startTask(lifecycleScope) { openIntent ->
if (openIntent == null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.export_failed, path))
.setPositiveButton(R.string.ok, null)
.show()
}.startTask(lifecycleScope) { (intent, error) ->
if (intent == null) {
onExportFailed(error!!)
} else {
startActivity(openIntent)
app.isStartingExternalApp = true
startActivity(intent)
}
app.isExporting = false
}
}
@ -644,8 +658,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
override fun onResume() {
super.onResume()
if (app.isStartingExternalApp) {
MemoryFileProvider.wipe()
DiskFileProvider.wipe()
TemporaryFileProvider.instance.wipe()
}
if (encryptedVolume.isClosed()) {
finish()

View File

@ -164,7 +164,7 @@ class ExplorerActivity : BaseExplorerActivity() {
} else {
val adapter = IconTextDialogAdapter(this)
adapter.items = listOf(
listOf("importFromOtherVolumes", R.string.import_from_other_volume, R.drawable.icon_transfert),
listOf("importFromOtherVolumes", R.string.import_from_other_volume, R.drawable.icon_transfer),
listOf("importFiles", R.string.import_files, R.drawable.icon_encrypt),
listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder),
listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown),
@ -385,26 +385,24 @@ class ExplorerActivity : BaseExplorerActivity() {
true
}
R.id.share -> {
app.isStartingExternalApp = true
val files = explorerAdapter.selectedItems.map { i ->
explorerElements[i].let {
Pair(it.fullPath, it.stat.size)
}
}
object : LoadingTask<Pair<Intent?, String?>>(this, theme, R.string.loading_msg_export) {
override suspend fun doTask(): Pair<Intent?, String?> {
return fileShare.share(files)
app.isExporting = true
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, R.string.loading_msg_export) {
override suspend fun doTask(): Pair<Intent?, Int?> {
return fileShare.share(files, volumeId)
}
}.startTask(lifecycleScope) { (intent, failedItem) ->
}.startTask(lifecycleScope) { (intent, error) ->
if (intent == null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.export_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
onExportFailed(error!!)
} else {
app.isStartingExternalApp = true
startActivity(Intent.createChooser(intent, getString(R.string.share_chooser)))
}
app.isExporting = false
}
unselectAll()
true

View File

@ -6,7 +6,7 @@ import sushi.hardcore.droidfs.util.PathUtils
import java.text.Collator
class ExplorerElement(val name: String, val stat: Stat, val parentPath: String) {
val fullPath: String = PathUtils.pathJoin(parentPath, name)
val fullPath: String = PathUtils.pathJoin(parentPath.ifEmpty { "/" }, name)
val collationKey = Collator.getInstance().getCollationKeyForFileName(fullPath)
val isDirectory: Boolean

View File

@ -160,7 +160,6 @@ abstract class EncryptedVolume: Parcelable {
if (written == length) {
offset += written
} else {
inputStream.close()
success = false
break
}

View File

@ -1,14 +1,17 @@
package sushi.hardcore.droidfs.filesystems
class Stat(val type: Int, var size: Long, val mTime: Long) {
class Stat(mode: Int, var size: Long, val mTime: Long) {
companion object {
private const val S_IFMT = 0xF000
const val S_IFDIR = 0x4000
const val S_IFREG = 0x8000
const val S_IFLNK = 0xA000
const val PARENT_FOLDER_TYPE = -1
const val PARENT_FOLDER_TYPE = 0xE000
fun parentFolderStat(): Stat {
return Stat(PARENT_FOLDER_TYPE, -1, -1)
}
}
val type = mode and S_IFMT
}

View File

@ -13,4 +13,11 @@ object Compat {
bundle.getParcelable(name)
}
}
val MEMFD_CREATE_MINIMUM_KERNEL_VERSION = Version("3.17")
fun isMemFileSupported(): Boolean {
val kernel = System.getProperty("os.version") ?: return false
return Version(kernel) >= MEMFD_CREATE_MINIMUM_KERNEL_VERSION
}
}

View File

@ -3,7 +3,7 @@ package sushi.hardcore.droidfs.util
import java.lang.Integer.max
class Version(inputVersion: String) : Comparable<Version> {
private var version: String
private val version: String
init {
val regex = "[0-9]+(\\.[0-9]+)*".toRegex()

View File

@ -1,24 +1,30 @@
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <jni.h>
#include <android/log.h>
const char* LOG_TAG = "MemFile";
void log_err(const char* function) {
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "%s(): %s", function, strerror(errno));
}
extern "C"
JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_MemFile_00024Companion_createMemFile(JNIEnv *env, jobject thiz, jstring jname,
jlong size) {
const char* name = env->GetStringUTFChars(jname, nullptr);
const char* name = (*env)->GetStringUTFChars(env, jname, NULL);
int fd = syscall(SYS_memfd_create, name, MFD_CLOEXEC);
if (fd < 0) return fd;
if (fd < 0) {
log_err("memfd_create");
return fd;
}
if (ftruncate64(fd, size) == -1) {
log_err("ftruncate64");
close(fd);
return -1;
}
return fd;
}
extern "C"
JNIEXPORT void JNICALL
Java_sushi_hardcore_droidfs_MemFile_close(JNIEnv *env, jobject thiz, jint fd) {
close(fd);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,5 @@
<vector android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?attr/colorAccent" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View File

@ -263,4 +263,14 @@
<string name="filesystem_id_changed">The filesystem id in the config file is different to the last time we opened this volume. This could mean an attacker replaced the filesystem with a different one.</string>
<string name="inaccessible_base_dir">The volume doesn\'t exist or is inaccessible.</string>
<string name="task_failed">The task failed: %s</string>
<string name="usf_expose">Expose open volumes</string>
<string name="usf_expose_summary">Allow other applications to browse open volumes as documents providers</string>
<string name="usf_saf_write">Grant write access</string>
<string name="usf_saf_write_summary">Grant write access when opening files with other applications</string>
<string name="saf">Storage Access Framework</string>
<string name="tmp_export_failed">Export failed: %s</string>
<string name="export_failed_create">can\'t create exported file</string>
<string name="export_failed_export">failed to export file</string>
<string name="export_mem">Exporting to memory…</string>
<string name="export_disk">Exporting to disk…</string>
</resources>

View File

@ -1,6 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory android:title="@string/about">
<Preference
android:icon="@drawable/icon_notes"
android:summary="@string/usf_read_doc"
android:title="@string/usf_doc">
<intent
android:action="android.intent.action.VIEW"
android:data="https://forge.chapril.org/hardcoresushi/DroidFS#unsafe-features" />
</Preference>
</PreferenceCategory>
<PreferenceCategory android:title="@string/ux">
<SwitchPreference
@ -18,23 +31,13 @@
android:icon="@drawable/icon_lock_open"
android:key="usf_decrypt"
android:title="@string/usf_decrypt" />
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/icon_open_in_new"
android:key="usf_open"
android:title="@string/usf_open" />
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/icon_share"
android:key="usf_share"
android:title="@string/usf_share" />
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/icon_lock_open"
android:key="usf_keep_open"
android:title="@string/usf_keep_open" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/usf_volume_management">
@ -45,18 +48,35 @@
android:key="usf_fingerprint"
android:title="@string/usf_fingerprint" />
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/icon_lock_open"
android:key="usf_keep_open"
android:title="@string/usf_keep_open" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/about">
<PreferenceCategory android:title="@string/saf">
<Preference
android:icon="@drawable/icon_notes"
android:summary="@string/usf_read_doc"
android:title="@string/usf_doc">
<intent
android:action="android.intent.action.VIEW"
android:data="https://forge.chapril.org/hardcoresushi/DroidFS#unsafe-features" />
</Preference>
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/icon_open_in_new"
android:key="usf_open"
android:title="@string/usf_open" />
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/icon_transfer"
android:key="usf_expose"
android:title="@string/usf_expose"
android:summary="@string/usf_expose_summary" />
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/icon_edit"
android:key="usf_saf_write"
android:title="@string/usf_saf_write"
android:summary="@string/usf_saf_write_summary" />
</PreferenceCategory>