Compare commits
1 Commits
master
...
volume-pro
Author | SHA1 | Date | |
---|---|---|---|
e851f381a0 |
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -160,7 +160,6 @@ abstract class EncryptedVolume: Parcelable {
|
||||
if (written == length) {
|
||||
offset += written
|
||||
} else {
|
||||
inputStream.close()
|
||||
success = false
|
||||
break
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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);
|
||||
}
|
BIN
app/src/main/res/drawable-hdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-hdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 819 B |
BIN
app/src/main/res/drawable-mdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-mdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 540 B |
BIN
app/src/main/res/drawable-xhdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 KiB |
BIN
app/src/main/res/drawable-xxhdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
5
app/src/main/res/drawable/icon_edit.xml
Normal file
5
app/src/main/res/drawable/icon_edit.xml
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user