Volume provider (unsecure!!!)

This commit is contained in:
Matéo Duparc 2023-08-28 00:35:12 +02:00
parent de0194a722
commit e851f381a0
Signed by: hardcoresushi
GPG Key ID: AFE384344A45E13A
29 changed files with 862 additions and 256 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

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

View File

@ -0,0 +1,280 @@
package sushi.hardcore.droidfs.content_providers
import android.content.Context
import android.content.Intent
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 notifyRootChanged(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
val previousVolumeIds = volumes.keys.toSet()
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.first)
}
volumes[volume.first.toString()] = volume
}
for (i in previousVolumeIds) {
if (!volumes.containsKey(i)) {
val uri = DocumentsContract.buildRootUri(AUTHORITY, i)
context?.revokeUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
}
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
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?
): ParcelFileDescriptor? {
if (!usfExpose) return null
val document = parseDocumentId(documentId) ?: return null
val size = document.encryptedVolume.getAttr(document.path)?.size ?: run {
Log.e(TAG, "stat() failed")
return null
}
val exportedFile = encryptedFileProvider.createFile(document.path, size) ?: run {
Log.e(TAG, "Can't create exported file")
return null
}
if (!encryptedFileProvider.exportFile(exportedFile, document.encryptedVolume)) {
Log.e(TAG, "File export failed")
return null
}
val result = encryptedFileProvider.openFile(
exportedFile,
mode,
document.encryptedVolume,
volumeManager.getCoroutineScope(document.volumeId),
true,
usfSafWrite,
)
when (result.second) {
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(TAG, "Unauthorized write access requested from $callingPackage")
else -> result.second.log()
}
return null
}
override fun createDocument(
parentDocumentId: String,
mimeType: String?,
displayName: String
): String? {
if (!usfExpose || !usfSafWrite) return null
val document = parseDocumentId(parentDocumentId) ?: return null
val newFile = PathUtils.pathJoin(document.path, displayName)
val f = document.encryptedVolume.openFileWriteMode(newFile)
return if (f == -1L) {
Log.e(TAG, "Failed to create file: $document")
null
} else {
document.encryptedVolume.closeFile(f)
document.rootId+"/"+newFile
}
}
override fun deleteDocument(documentId: String) {
if (!usfExpose || !usfSafWrite) return
fun recursiveRemoveDirectory(document: DocumentData) {
document.encryptedVolume.readDir(document.path)?.forEach { e ->
val childPath = PathUtils.pathJoin(document.path, e.name)
if (e.isDirectory) {
recursiveRemoveDirectory(document.child(childPath))
} else {
document.encryptedVolume.deleteFile(childPath)
}
revokeDocumentPermission(document.rootId+childPath)
}
document.encryptedVolume.rmdir(document.path)
}
val document = parseDocumentId(documentId) ?: return
document.encryptedVolume.getAttr(document.path)?.let { stat ->
if (stat.type == Stat.S_IFDIR) {
recursiveRemoveDirectory(document)
} else {
document.encryptedVolume.deleteFile(document.path)
}
}
}
override fun renameDocument(documentId: String, displayName: String): String {
if (!usfExpose || !usfSafWrite) return documentId
val document = parseDocumentId(documentId) ?: return documentId
val newPath = PathUtils.pathJoin(PathUtils.getParentPath(document.path), displayName)
return if (document.encryptedVolume.rename(document.path, newPath)) {
document.rootId+newPath
} else {
documentId
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

View File

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

View File

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