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(GOCRYPTFS "build libgocryptfs" ON)
option(CRYFS "build libcryfs" 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) if (GOCRYPTFS)
add_library(gocryptfs SHARED IMPORTED) add_library(gocryptfs SHARED IMPORTED)

View File

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

View File

@ -160,7 +160,7 @@ class ChangePasswordActivity: BaseActivity() {
) )
} }
if (success) { if (success) {
if (volumeDatabase.isHashSaved(volume.name)) { if (volumeDatabase.isHashSaved(volume)) {
volumeDatabase.removeHash(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.content.Intent
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import sushi.hardcore.droidfs.content_providers.DiskFileProvider import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.content_providers.MemoryFileProvider
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Version
import java.io.File import java.io.File
class FileShare(private val encryptedVolume: EncryptedVolume, private val context: Context) { class FileShare(context: Context) {
companion object { companion object {
private const val content_type_all = "*/*" private const val CONTENT_TYPE_ANY = "*/*"
fun getContentType(filename: String, previousContentType: String?): String { private fun getContentType(filename: String, previousContentType: String?): String {
if (content_type_all != previousContentType) { if (CONTENT_TYPE_ANY != previousContentType) {
var contentType = MimeTypeMap.getSingleton() var contentType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(File(filename).extension) .getMimeTypeFromExtension(File(filename).extension)
if (contentType == null) { if (contentType == null) {
contentType = content_type_all contentType = CONTENT_TYPE_ANY
} }
if (previousContentType == null) { if (previousContentType == null) {
return contentType return contentType
} else if (previousContentType != contentType) { } else if (previousContentType != contentType) {
return content_type_all return CONTENT_TYPE_ANY
} }
} }
return previousContentType return previousContentType
} }
} }
private val fileProvider: TemporaryFileProvider<*> private val usfSafWrite = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("usf_saf_write", false)
init { private fun exportFile(exportedFile: EncryptedFileProvider.ExportedFile, size: Long, volumeId: Int, previousContentType: String? = null): Pair<Uri, String>? {
var provider: MemoryFileProvider? = null val uri = TemporaryFileProvider.instance.exportFile(exportedFile, size, volumeId) ?: return null
System.getProperty("os.version")?.let { return Pair(uri, getContentType(File(exportedFile.path).name, previousContentType))
if (Version(it) >= Version("3.17")) {
provider = MemoryFileProvider()
}
}
fileProvider = provider ?: DiskFileProvider()
} }
private fun exportFile(path: String, size: Long, previousContentType: String? = null): Pair<Uri?, String?> { fun share(files: List<Pair<String, Long>>, volumeId: Int): Pair<Intent?, Int?> {
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?> {
var contentType: String? = null var contentType: String? = null
val uris = ArrayList<Uri>(files.size) val uris = ArrayList<Uri>(files.size)
for ((path, size) in files) { for ((path, size) in files) {
val result = exportFile(path, size, contentType) val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
contentType = if (result.first == null) { ?: return Pair(null, R.string.export_failed_create)
return Pair(null, path) val result = exportFile(exportedFile, size, volumeId, contentType)
contentType = if (result == null) {
return Pair(null, R.string.export_failed_export)
} else { } else {
uris.add(result.first!!) uris.add(result.first)
result.second result.second
} }
} }
@ -79,15 +61,18 @@ class FileShare(private val encryptedVolume: EncryptedVolume, private val contex
}, null) }, null)
} }
fun openWith(path: String, size: Long): Intent? { fun openWith(exportedFile: EncryptedFileProvider.ExportedFile, size: Long, volumeId: Int): Pair<Intent?, Int?> {
val result = exportFile(path, size) val result = exportFile(exportedFile, size, volumeId)
return if (result.first != null) { return if (result == null) {
Intent(Intent.ACTION_VIEW).apply { Pair(null, R.string.export_failed_export)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
setDataAndType(result.first, result.second)
}
} else { } 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.Constants.DEFAULT_VOLUME_KEY
import sushi.hardcore.droidfs.adapters.VolumeAdapter import sushi.hardcore.droidfs.adapters.VolumeAdapter
import sushi.hardcore.droidfs.add_volume.AddVolumeActivity 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.ActivityMainBinding
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter import sushi.hardcore.droidfs.explorers.ExplorerRouter
@ -195,7 +196,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
private fun removeVolume(volume: VolumeData) { private fun removeVolume(volume: VolumeData) {
volumeManager.getVolumeId(volume)?.let { volumeManager.closeVolume(it) } 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) { 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(File(volume.name)),
DocumentFile.fromFile(hiddenVolumeFile.parentFile!!), 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) val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
if (path == null) null if (path == null) null
else VolumeData( else VolumeData(
VolumeData.newUuid(),
PathUtils.pathJoin(path, name), PathUtils.pathJoin(path, name),
false, false,
volume.type, volume.type,
@ -466,7 +475,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
DocumentFile.fromFile(srcPath).renameTo(newName) DocumentFile.fromFile(srcPath).renameTo(newName)
} }
if (success) { if (success) {
volumeDatabase.renameVolume(volume.name, newDBName) volumeDatabase.renameVolume(volume, newDBName)
VolumeProvider.notifyRootsChanged(this)
unselect(position) unselect(position)
if (volume.name == volumeOpener.defaultVolumeName) { if (volume.name == volumeOpener.defaultVolumeName) {
with (sharedPrefs.edit()) { with (sharedPrefs.edit()) {

View File

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

View File

@ -7,8 +7,16 @@ import android.os.Bundle
import android.text.InputType import android.text.InputType
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast 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.databinding.ActivitySettingsBinding
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -150,6 +158,47 @@ class SettingsActivity : BaseActivity() {
true 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 sushi.hardcore.droidfs.util.PathUtils
import java.io.File import java.io.File
import java.io.FileInputStream 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( constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!, parcel.readString()!!,
parcel.readByte() != 0.toByte(), parcel.readByte() != 0.toByte(),
parcel.readByte(), parcel.readByte(),
@ -23,12 +32,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
File(name).name File(name).name
} }
fun getFullPath(filesDir: String): String { fun getFullPath(filesDir: String) = getFullPath(name, isHidden, filesDir)
return if (isHidden)
getHiddenVolumeFullPath(filesDir, name)
else
name
}
fun canRead(filesDir: String): Boolean { fun canRead(filesDir: String): Boolean {
val volumePath = getFullPath(filesDir) 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) { override fun writeToParcel(dest: Parcel, flags: Int) {
with (dest) { with (dest) {
writeString(uuid)
writeString(name) writeString(name)
writeByte(if (isHidden) 1 else 0) writeByte(if (isHidden) 1 else 0)
writeByte(type) writeByte(type)
@ -74,12 +79,10 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
if (other !is VolumeData) { if (other !is VolumeData) {
return false return false
} }
return other.name == name && other.isHidden == isHidden return other.uuid == uuid
} }
override fun hashCode(): Int { override fun hashCode() = uuid.hashCode()
return name.hashCode()+isHidden.hashCode()
}
companion object { companion object {
const val VOLUMES_DIRECTORY = "volumes" 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) override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
} }
fun newUuid(): String = UUID.randomUUID().toString()
fun getHiddenVolumeFullPath(filesDir: String, name: String): String { fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name) 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.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import java.io.File 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 { companion object {
const val TAG = "VolumeDatabase" private const val TAG = "VolumeDatabase"
const val TABLE_NAME = "Volumes" private const val TABLE_NAME = "Volumes"
const val COLUMN_NAME = "name" private const val COLUMN_UUID = "uuid"
const val COLUMN_HIDDEN = "hidden" private const val COLUMN_NAME = "name"
const val COLUMN_TYPE = "type" private const val COLUMN_HIDDEN = "hidden"
const val COLUMN_HASH = "hash" private const val COLUMN_TYPE = "type"
const val COLUMN_IV = "iv" private const val COLUMN_HASH = "hash"
private 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
}
} }
override fun onCreate(db: SQLiteDatabase) {
private fun createTable(db: SQLiteDatabase) =
db.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" + "CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
"$COLUMN_NAME TEXT PRIMARY KEY," + "$COLUMN_UUID TEXT PRIMARY KEY," +
"$COLUMN_HIDDEN SHORT," + "$COLUMN_NAME TEXT," +
"$COLUMN_TYPE BLOB," + "$COLUMN_HIDDEN SHORT," +
"$COLUMN_HASH BLOB," + "$COLUMN_TYPE BLOB," +
"$COLUMN_IV BLOB" + "$COLUMN_HASH BLOB," +
");" "$COLUMN_IV BLOB" +
");"
) )
override fun onCreate(db: SQLiteDatabase) {
createTable(db)
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir() File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()
} }
private fun getNewVolumePath(volumeName: String): File { private fun getNewVolumePath(volumeName: String): File {
return File( return File(
VolumeData( VolumeData.getFullPath(volumeName, true, context.filesDir.path)
volumeName,
true,
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE
).getFullPath(context.filesDir.path)
).canonicalFile ).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 { private fun extractVolumeData(cursor: Cursor): VolumeData {
return VolumeData( return VolumeData(
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)),
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)), cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(), cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(),
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE))[0], 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 { fun saveVolume(volume: VolumeData): Boolean {
if (!isVolumeSaved(volume.name, volume.isHidden)) { 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 return false
} }
@ -157,8 +176,8 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
return list return list
} }
fun isHashSaved(volumeName: String): Boolean { fun isHashSaved(volume: VolumeData): Boolean {
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME, COLUMN_HASH), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null) val cursor = readableDatabase.rawQuery("SELECT $COLUMN_HASH FROM $TABLE_NAME WHERE $COLUMN_UUID=?", arrayOf(volume.uuid))
var isHashSaved = false var isHashSaved = false
if (cursor.moveToNext()) { if (cursor.moveToNext()) {
if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) { 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 { 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 { fun removeHash(volume: VolumeData): Boolean {
return writableDatabase.update( return writableDatabase.update(
TABLE_NAME, contentValuesFromVolume( TABLE_NAME,
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,
ContentValues().apply { ContentValues().apply {
put(COLUMN_NAME, newName) put(COLUMN_HASH, null as ByteArray?)
}, put(COLUMN_IV, null as ByteArray?)
"$COLUMN_NAME=?",arrayOf(oldName) }, "$COLUMN_UUID=?", arrayOf(volume.uuid)
) > 0 ) > 0
} }
fun removeVolume(volumeName: String): Boolean { fun renameVolume(volume: VolumeData, newName: String): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volumeName)) > 0 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 package sushi.hardcore.droidfs
import android.content.Context
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
class VolumeManager { class VolumeManager(private val context: Context) {
private var id = 0 private var id = 0
private val volumes = HashMap<Int, EncryptedVolume>() private val volumes = HashMap<Int, EncryptedVolume>()
private val volumesData = HashMap<VolumeData, Int>() private val volumesData = HashMap<VolumeData, Int>()
@ -15,6 +17,7 @@ class VolumeManager {
fun insert(volume: EncryptedVolume, data: VolumeData): Int { fun insert(volume: EncryptedVolume, data: VolumeData): Int {
volumes[id] = volume volumes[id] = volume
volumesData[data] = id volumesData[data] = id
VolumeProvider.notifyRootsChanged(context)
return id++ return id++
} }
@ -30,6 +33,10 @@ class VolumeManager {
return volumes[id] return volumes[id]
} }
fun listVolumes(): List<Pair<Int, VolumeData>> {
return volumesData.map { (data, id) -> Pair(id, data) }
}
fun getCoroutineScope(volumeId: Int): CoroutineScope { fun getCoroutineScope(volumeId: Int): CoroutineScope {
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it } return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
} }
@ -41,6 +48,7 @@ class VolumeManager {
volumesData.filter { it.value == id }.forEach { volumesData.filter { it.value == id }.forEach {
volumesData.remove(it.key) volumesData.remove(it.key)
} }
VolumeProvider.notifyRootsChanged(context)
} }
} }
@ -51,5 +59,6 @@ class VolumeManager {
} }
volumes.clear() volumes.clear()
volumesData.clear() volumesData.clear()
VolumeProvider.notifyRootsChanged(context)
} }
} }

View File

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

View File

@ -211,11 +211,11 @@ class CreateVolumeFragment: Fragment() {
.show() .show()
} else { } else {
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath 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 var isVolumeSaved = false
volumeDatabase.apply { volumeDatabase.apply {
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
removeVolume(volumeName) removeVolume(volume)
if (rememberVolume) { if (rememberVolume) {
isVolumeSaved = saveVolume(volume) isVolumeSaved = saveVolume(volume)
} }

View File

@ -350,7 +350,7 @@ class SelectPathFragment: Fragment() {
} }
private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) { 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) { if (binding.switchRemember.isChecked) {
volumeDatabase.saveVolume(volumeData) 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.ContentProvider
import android.content.ContentValues import android.content.ContentValues
import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.database.MatrixCursor import android.database.MatrixCursor
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap 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.io.File
import java.util.UUID
abstract class TemporaryFileProvider<T>: ContentProvider() { class TemporaryFileProvider : ContentProvider() {
protected inner class SharedFile(val name: String, val size: Long, val file: T) private inner class ProvidedFile(
val file: EncryptedFileProvider.ExportedFile,
val size: Long,
val volumeId: Int
)
protected abstract fun getFile(uri: Uri): SharedFile? companion object {
abstract fun newFile(name: String, size: Long): Uri? 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? { lateinit var instance: TemporaryFileProvider
val file = getFile(uri) ?: return null 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 { 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") 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") 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) MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(it).extension)
} ?: "application/octet-stream" } ?: "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 kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.BaseActivity import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.Constants import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.FileShare import sushi.hardcore.droidfs.FileShare
import sushi.hardcore.droidfs.FileTypes import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.LoadingTask import sushi.hardcore.droidfs.LoadingTask
@ -36,8 +37,7 @@ import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManagerApp import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
import sushi.hardcore.droidfs.content_providers.DiskFileProvider import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.content_providers.MemoryFileProvider
import sushi.hardcore.droidfs.file_operations.FileOperationService import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.OperationFile import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.file_operations.TaskResult 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 numberOfFilesText: TextView
private lateinit var numberOfFoldersText: TextView private lateinit var numberOfFoldersText: TextView
private lateinit var totalSizeText: TextView private lateinit var totalSizeText: TextView
protected val fileShare by lazy { protected val fileShare by lazy { FileShare(this) }
FileShare(encryptedVolume, this)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -204,22 +202,38 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
startActivity(intent) 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) { private fun openWithExternalApp(path: String, size: Long) {
app.isStartingExternalApp = true app.isExporting = true
object : LoadingTask<Intent?>(this, theme, R.string.loading_msg_export) { val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
override suspend fun doTask(): Intent? { if (exportedFile == null) {
return fileShare.openWith(path, size) 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 -> }.startTask(lifecycleScope) { (intent, error) ->
if (openIntent == null) { if (intent == null) {
CustomAlertDialogBuilder(this, theme) onExportFailed(error!!)
.setTitle(R.string.error)
.setMessage(getString(R.string.export_failed, path))
.setPositiveButton(R.string.ok, null)
.show()
} else { } else {
startActivity(openIntent) app.isStartingExternalApp = true
startActivity(intent)
} }
app.isExporting = false
} }
} }
@ -644,8 +658,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (app.isStartingExternalApp) { if (app.isStartingExternalApp) {
MemoryFileProvider.wipe() TemporaryFileProvider.instance.wipe()
DiskFileProvider.wipe()
} }
if (encryptedVolume.isClosed()) { if (encryptedVolume.isClosed()) {
finish() finish()

View File

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

View File

@ -6,7 +6,7 @@ import sushi.hardcore.droidfs.util.PathUtils
import java.text.Collator import java.text.Collator
class ExplorerElement(val name: String, val stat: Stat, val parentPath: String) { 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 collationKey = Collator.getInstance().getCollationKeyForFileName(fullPath)
val isDirectory: Boolean val isDirectory: Boolean

View File

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

View File

@ -1,14 +1,17 @@
package sushi.hardcore.droidfs.filesystems 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 { companion object {
private const val S_IFMT = 0xF000
const val S_IFDIR = 0x4000 const val S_IFDIR = 0x4000
const val S_IFREG = 0x8000 const val S_IFREG = 0x8000
const val S_IFLNK = 0xA000 const val S_IFLNK = 0xA000
const val PARENT_FOLDER_TYPE = -1 const val PARENT_FOLDER_TYPE = 0xE000
fun parentFolderStat(): Stat { fun parentFolderStat(): Stat {
return Stat(PARENT_FOLDER_TYPE, -1, -1) return Stat(PARENT_FOLDER_TYPE, -1, -1)
} }
} }
val type = mode and S_IFMT
} }

View File

@ -13,4 +13,11 @@ object Compat {
bundle.getParcelable(name) 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 import java.lang.Integer.max
class Version(inputVersion: String) : Comparable<Version> { class Version(inputVersion: String) : Comparable<Version> {
private var version: String private val version: String
init { init {
val regex = "[0-9]+(\\.[0-9]+)*".toRegex() 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/mman.h>
#include <sys/syscall.h> #include <sys/syscall.h>
#include <unistd.h> #include <unistd.h>
#include <jni.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 JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_MemFile_00024Companion_createMemFile(JNIEnv *env, jobject thiz, jstring jname, Java_sushi_hardcore_droidfs_MemFile_00024Companion_createMemFile(JNIEnv *env, jobject thiz, jstring jname,
jlong size) { 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); 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) { if (ftruncate64(fd, size) == -1) {
log_err("ftruncate64");
close(fd); close(fd);
return -1; return -1;
} }
return fd; 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="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="inaccessible_base_dir">The volume doesn\'t exist or is inaccessible.</string>
<string name="task_failed">The task failed: %s</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> </resources>

View File

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