diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 2d67425..189d9c1 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c4ed4c4..46b3699 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,10 +3,6 @@ xmlns:tools="http://schemas.android.com/tools" android:installLocation="auto"> - - @@ -68,16 +64,21 @@ + android:name=".content_providers.TemporaryFileProvider" + android:authorities="${applicationId}.temporary_provider" + android:exported="true"/> + android:grantUriPermissions="true" + android:permission="android.permission.MANAGE_DOCUMENTS"> + + + + + diff --git a/app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt index 847a7c9..5d9ec04 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt @@ -160,7 +160,7 @@ class ChangePasswordActivity: BaseActivity() { ) } if (success) { - if (volumeDatabase.isHashSaved(volume.name)) { + if (volumeDatabase.isHashSaved(volume)) { volumeDatabase.removeHash(volume) } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/EncryptedFileProvider.kt b/app/src/main/java/sushi/hardcore/droidfs/EncryptedFileProvider.kt new file mode 100644 index 0000000..5a667b6 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/EncryptedFileProvider.kt @@ -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 { + 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) + } + } + } + } +} diff --git a/app/src/main/java/sushi/hardcore/droidfs/FileShare.kt b/app/src/main/java/sushi/hardcore/droidfs/FileShare.kt index 14b5e2b..f6190ad 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/FileShare.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/FileShare.kt @@ -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? { + 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 { - 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 { + fun share(files: List>, volumeId: Int): Pair { var contentType: String? = null val uris = ArrayList(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 { + 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) } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt index 8b68f53..67faa69 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt @@ -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, 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()) { diff --git a/app/src/main/java/sushi/hardcore/droidfs/MemFile.kt b/app/src/main/java/sushi/hardcore/droidfs/MemFile.kt index d8b3fcb..0c76ad5 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/MemFile.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/MemFile.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/SettingsActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/SettingsActivity.kt index 27840ff..f455eff 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/SettingsActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/SettingsActivity.kt @@ -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("usf_keep_open")!! + val switchExternalOpen = findPreference("usf_open")!! + val switchExpose = findPreference("usf_expose")!! + val switchSafWrite = findPreference("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 + } } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/VolumeData.kt b/app/src/main/java/sushi/hardcore/droidfs/VolumeData.kt index ea4f449..5589e8b 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/VolumeData.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/VolumeData.kt @@ -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(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 + } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/VolumeDatabase.kt b/app/src/main/java/sushi/hardcore/droidfs/VolumeDatabase.kt index 732b69d..ae29df2 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/VolumeDatabase.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/VolumeDatabase.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/VolumeManager.kt b/app/src/main/java/sushi/hardcore/droidfs/VolumeManager.kt index 4bb26dc..6a9b5ec 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/VolumeManager.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/VolumeManager.kt @@ -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() private val volumesData = HashMap() @@ -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> { + 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) } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/VolumeManagerApp.kt b/app/src/main/java/sushi/hardcore/droidfs/VolumeManagerApp.kt index 94849fc..6e49652 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/VolumeManagerApp.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/VolumeManagerApp.kt @@ -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.onCreate() @@ -46,8 +46,9 @@ class VolumeManagerApp : Application(), DefaultLifecycleObserver { if (!usfKeepOpen) { volumeManager.closeAll() } - DiskFileProvider.wipe() - MemoryFileProvider.wipe() + if (!usfKeepOpen || !isExporting) { + TemporaryFileProvider.instance.wipe() + } } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt b/app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt index 90c3bf6..c472a91 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt @@ -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) } diff --git a/app/src/main/java/sushi/hardcore/droidfs/add_volume/SelectPathFragment.kt b/app/src/main/java/sushi/hardcore/droidfs/add_volume/SelectPathFragment.kt index a1c6478..9215e60 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/add_volume/SelectPathFragment.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/add_volume/SelectPathFragment.kt @@ -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) } diff --git a/app/src/main/java/sushi/hardcore/droidfs/content_providers/DiskFileProvider.kt b/app/src/main/java/sushi/hardcore/droidfs/content_providers/DiskFileProvider.kt deleted file mode 100644 index 87d0903..0000000 --- a/app/src/main/java/sushi/hardcore/droidfs/content_providers/DiskFileProvider.kt +++ /dev/null @@ -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() { - 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.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?): 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") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/content_providers/MemoryFileProvider.kt b/app/src/main/java/sushi/hardcore/droidfs/content_providers/MemoryFileProvider.kt deleted file mode 100644 index f6bf759..0000000 --- a/app/src/main/java/sushi/hardcore/droidfs/content_providers/MemoryFileProvider.kt +++ /dev/null @@ -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() { - companion object { - private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".memory_provider" - private val BASE_URI: Uri = Uri.parse("content://$AUTHORITY") - - private var files = HashMap.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?): 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() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/content_providers/TemporaryFileProvider.kt b/app/src/main/java/sushi/hardcore/droidfs/content_providers/TemporaryFileProvider.kt index 52658bb..4d227cb 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/content_providers/TemporaryFileProvider.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/content_providers/TemporaryFileProvider.kt @@ -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: 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?, selection: String?, selectionArgs: Array?, 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() + + 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?, + selection: String?, + selectionArgs: Array?, + 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: ContentProvider() { throw UnsupportedOperationException("Operation not supported") } - override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): 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?): 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() + } + } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/content_providers/VolumeProvider.kt b/app/src/main/java/sushi/hardcore/droidfs/content_providers/VolumeProvider.kt new file mode 100644 index 0000000..a901b84 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/content_providers/VolumeProvider.kt @@ -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>() + 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?): 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?): 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?, + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt index 643081a..f07c792 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt @@ -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(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>(this, theme, msg) { + override suspend fun doTask(): Pair { + 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() diff --git a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt index 7a65aee..6f0e619 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt @@ -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>(this, theme, R.string.loading_msg_export) { - override suspend fun doTask(): Pair { - return fileShare.share(files) + app.isExporting = true + object : LoadingTask>(this, theme, R.string.loading_msg_export) { + override suspend fun doTask(): Pair { + 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 diff --git a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerElement.kt b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerElement.kt index e128d31..21cfaee 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerElement.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerElement.kt @@ -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 diff --git a/app/src/main/java/sushi/hardcore/droidfs/filesystems/EncryptedVolume.kt b/app/src/main/java/sushi/hardcore/droidfs/filesystems/EncryptedVolume.kt index 1f270d6..6e3a4a8 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/filesystems/EncryptedVolume.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/filesystems/EncryptedVolume.kt @@ -160,7 +160,6 @@ abstract class EncryptedVolume: Parcelable { if (written == length) { offset += written } else { - inputStream.close() success = false break } diff --git a/app/src/main/java/sushi/hardcore/droidfs/filesystems/Stat.kt b/app/src/main/java/sushi/hardcore/droidfs/filesystems/Stat.kt index e08545f..a610688 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/filesystems/Stat.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/filesystems/Stat.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/util/Compat.kt b/app/src/main/java/sushi/hardcore/droidfs/util/Compat.kt index 5addff5..1b58bb7 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/util/Compat.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/util/Compat.kt @@ -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 + } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/util/Version.kt b/app/src/main/java/sushi/hardcore/droidfs/util/Version.kt index 844f0fc..9b98d90 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/util/Version.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/util/Version.kt @@ -3,7 +3,7 @@ package sushi.hardcore.droidfs.util import java.lang.Integer.max class Version(inputVersion: String) : Comparable { - private var version: String + private val version: String init { val regex = "[0-9]+(\\.[0-9]+)*".toRegex() diff --git a/app/src/main/native/memfile.cpp b/app/src/main/native/memfile.c similarity index 52% rename from app/src/main/native/memfile.cpp rename to app/src/main/native/memfile.c index 8f71128..c0951b1 100644 --- a/app/src/main/native/memfile.cpp +++ b/app/src/main/native/memfile.c @@ -1,24 +1,30 @@ +#include +#include #include #include #include #include +#include + +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); } \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/icon_document_provider.png b/app/src/main/res/drawable-hdpi/icon_document_provider.png new file mode 100644 index 0000000..a314350 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/icon_document_provider.png differ diff --git a/app/src/main/res/drawable-mdpi/icon_document_provider.png b/app/src/main/res/drawable-mdpi/icon_document_provider.png new file mode 100644 index 0000000..4f52db5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/icon_document_provider.png differ diff --git a/app/src/main/res/drawable-xhdpi/icon_document_provider.png b/app/src/main/res/drawable-xhdpi/icon_document_provider.png new file mode 100644 index 0000000..754bfa2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/icon_document_provider.png differ diff --git a/app/src/main/res/drawable-xxhdpi/icon_document_provider.png b/app/src/main/res/drawable-xxhdpi/icon_document_provider.png new file mode 100644 index 0000000..0fb2781 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/icon_document_provider.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/icon_document_provider.png b/app/src/main/res/drawable-xxxhdpi/icon_document_provider.png new file mode 100644 index 0000000..c38fbb9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/icon_document_provider.png differ diff --git a/app/src/main/res/drawable/icon_edit.xml b/app/src/main/res/drawable/icon_edit.xml new file mode 100644 index 0000000..6af5f99 --- /dev/null +++ b/app/src/main/res/drawable/icon_edit.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/icon_transfert.xml b/app/src/main/res/drawable/icon_transfer.xml similarity index 100% rename from app/src/main/res/drawable/icon_transfert.xml rename to app/src/main/res/drawable/icon_transfer.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f49f63c..216cc06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,4 +263,14 @@ 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. The volume doesn\'t exist or is inaccessible. The task failed: %s + Expose open volumes + Allow other applications to browse open volumes as documents providers + Grant write access + Grant write access when opening files with other applications + Storage Access Framework + Export failed: %s + can\'t create exported file + failed to export file + Exporting to memory… + Exporting to disk… diff --git a/app/src/main/res/xml/unsafe_features_preferences.xml b/app/src/main/res/xml/unsafe_features_preferences.xml index a1237f9..13f02b4 100644 --- a/app/src/main/res/xml/unsafe_features_preferences.xml +++ b/app/src/main/res/xml/unsafe_features_preferences.xml @@ -1,6 +1,19 @@ + + + + + + + + - + - - @@ -45,18 +48,35 @@ android:key="usf_fingerprint" android:title="@string/usf_fingerprint" /> + + - + - - - + + + + +