Compare commits

..

12 Commits

17 changed files with 647 additions and 460 deletions

View File

@ -9,6 +9,8 @@ import android.system.Os
import android.util.Log import android.util.Log
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Compat import sushi.hardcore.droidfs.util.Compat
@ -86,9 +88,11 @@ class EncryptedFileProvider(context: Context) {
} }
override fun free() { override fun free() {
GlobalScope.launch(Dispatchers.IO) {
Wiper.wipe(file) Wiper.wipe(file)
} }
} }
}
class ExportedMemFile private constructor(path: String, private val file: MemFile) : class ExportedMemFile private constructor(path: String, private val file: MemFile) :
ExportedFile(path) { ExportedFile(path) {
@ -160,8 +164,10 @@ class EncryptedFileProvider(context: Context) {
exportedFile: ExportedFile, exportedFile: ExportedFile,
encryptedVolume: EncryptedVolume, encryptedVolume: EncryptedVolume,
): Boolean { ): Boolean {
val fd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false).fileDescriptor val pfd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false)
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(fd)) return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(pfd.fileDescriptor)).also {
pfd.close()
}
} }
enum class Error { enum class Error {

View File

@ -4,8 +4,8 @@ import java.io.File
object FileTypes { object FileTypes {
private val FILE_EXTENSIONS = mapOf( private val FILE_EXTENSIONS = mapOf(
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")), Pair("image", listOf("png", "jpg", "jpeg", "gif", "avif", "webp", "bmp", "heic")),
Pair("video", listOf("mp4", "webm", "mkv", "mov")), Pair("video", listOf("mp4", "webm", "mkv", "mov", "m4v")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac", "opus")), Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac", "opus")),
Pair("pdf", listOf("pdf")), Pair("pdf", listOf("pdf")),
Pair("text", listOf( Pair("text", listOf(

View File

@ -11,10 +11,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.databinding.ActivityLogcatBinding import sushi.hardcore.droidfs.databinding.ActivityLogcatBinding
import java.io.BufferedReader import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.InterruptedIOException import java.io.InterruptedIOException
import java.io.OutputStreamWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -40,12 +38,10 @@ class LogcatActivity: BaseActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
BufferedReader(InputStreamReader(Runtime.getRuntime().exec("logcat").also { BufferedReader(InputStreamReader(ProcessBuilder("logcat", "-T", "500").start().also {
process = it process = it
}.inputStream)).forEachLine { }.inputStream)).lineSequence().forEach {
binding.content.post { binding.content.append(it)
binding.content.append("$it\n")
}
} }
} catch (_: InterruptedIOException) {} } catch (_: InterruptedIOException) {}
} }
@ -77,8 +73,9 @@ class LogcatActivity: BaseActivity() {
private fun saveTo(uri: Uri) { private fun saveTo(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
BufferedWriter(OutputStreamWriter(contentResolver.openOutputStream(uri))).use { contentResolver.openOutputStream(uri)?.use { output ->
it.write(binding.content.text.toString()) Runtime.getRuntime().exec("logcat -d").inputStream.use { input ->
input.copyTo(output)
} }
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
Toast.makeText(this@LogcatActivity, R.string.logcat_saved, Toast.LENGTH_SHORT).show() Toast.makeText(this@LogcatActivity, R.string.logcat_saved, Toast.LENGTH_SHORT).show()
@ -86,3 +83,4 @@ class LogcatActivity: BaseActivity() {
} }
} }
} }
}

View File

@ -328,8 +328,8 @@ class ExplorerActivity : BaseExplorerActivity() {
activityScope.launch { activityScope.launch {
onTaskResult( onTaskResult(
fileOperationService.moveElements(volumeId, toMove, toClean), fileOperationService.moveElements(volumeId, toMove, toClean),
R.string.move_success,
R.string.move_failed, R.string.move_failed,
R.string.move_success,
) )
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
} }

View File

@ -104,7 +104,9 @@ class FileOperationService : Service() {
activity.lifecycle.addObserver(object : DefaultLifecycleObserver { activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) { override fun onDestroy(owner: LifecycleOwner) {
activity.unbindService(serviceConnection) activity.unbindService(serviceConnection)
service.notificationPermissionHelpers.removeLast() // Could have been more efficient with a LinkedHashMap but the JDK implementation doesn't allow
// to access the latest element in O(1) unless using reflection
service.notificationPermissionHelpers.removeAll { it.activity == activity }
} }
}) })
activity.bindService( activity.bindService(

View File

@ -14,6 +14,8 @@ import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.BaseActivity import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.FileTypes import sushi.hardcore.droidfs.FileTypes
@ -21,7 +23,6 @@ import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManagerApp import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.finishOnClose import sushi.hardcore.droidfs.util.finishOnClose
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
@ -32,9 +33,8 @@ abstract class FileViewerActivity: BaseActivity() {
private lateinit var originalParentPath: String private lateinit var originalParentPath: String
private lateinit var windowInsetsController: WindowInsetsControllerCompat private lateinit var windowInsetsController: WindowInsetsControllerCompat
private var windowTypeMask = 0 private var windowTypeMask = 0
private var foldersFirst = true protected val playlist = mutableListOf<ExplorerElement>()
private var wasMapped = false private val playlistMutex = Mutex()
protected val mappedPlaylist = mutableListOf<ExplorerElement>()
protected var currentPlaylistIndex = -1 protected var currentPlaylistIndex = -1
private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
@ -46,7 +46,6 @@ abstract class FileViewerActivity: BaseActivity() {
intent.getIntExtra("volumeId", -1) intent.getIntExtra("volumeId", -1)
)!! )!!
finishOnClose(encryptedVolume) finishOnClose(encryptedVolume)
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView) windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask -> windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
windowTypeMask = typeMask windowTypeMask = typeMask
@ -131,48 +130,57 @@ abstract class FileViewerActivity: BaseActivity() {
} }
} }
protected fun createPlaylist() { protected suspend fun createPlaylist() {
if (!wasMapped){ playlistMutex.withLock {
encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements -> if (currentPlaylistIndex != -1) {
for (e in elements) { // playlist already initialized
if (e.isRegularFile) { return
if (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
mappedPlaylist.add(e)
}
}
} }
withContext(Dispatchers.IO) {
if (sharedPrefs.getBoolean("map_folders", true)) {
encryptedVolume.recursiveMapFiles(originalParentPath)
} else {
encryptedVolume.readDir(originalParentPath)
}?.filterTo(playlist) { e ->
e.isRegularFile && (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath)
} }
val sortOrder = intent.getStringExtra("sortOrder") ?: "name" val sortOrder = intent.getStringExtra("sortOrder") ?: "name"
ExplorerElement.sortBy(sortOrder, foldersFirst, mappedPlaylist) val foldersFirst = sharedPrefs.getBoolean("folders_first", true)
//find current index ExplorerElement.sortBy(sortOrder, foldersFirst, playlist)
for ((i, e) in mappedPlaylist.withIndex()){ currentPlaylistIndex = playlist.indexOfFirst { it.fullPath == filePath }
if (filePath == e.fullPath){
currentPlaylistIndex = i
break
} }
} }
wasMapped = true
}
} }
protected fun playlistNext(forward: Boolean) { private fun updateCurrentItem() {
filePath = playlist[currentPlaylistIndex].fullPath
}
protected suspend fun playlistNext(forward: Boolean) {
createPlaylist() createPlaylist()
currentPlaylistIndex = if (forward) { currentPlaylistIndex = if (forward) {
(currentPlaylistIndex+1)%mappedPlaylist.size (currentPlaylistIndex + 1).mod(playlist.size)
} else { } else {
var x = (currentPlaylistIndex-1)%mappedPlaylist.size (currentPlaylistIndex - 1).mod(playlist.size)
if (x < 0) {
x += mappedPlaylist.size
} }
x updateCurrentItem()
}
filePath = mappedPlaylist[currentPlaylistIndex].fullPath
} }
protected fun refreshPlaylist() { protected suspend fun deleteCurrentFile(): Boolean {
mappedPlaylist.clear() createPlaylist() // ensure we know the current position in the playlist
wasMapped = false return if (encryptedVolume.deleteFile(filePath)) {
createPlaylist() playlist.removeAt(currentPlaylistIndex)
if (playlist.size != 0) {
if (currentPlaylistIndex == playlist.size) {
// deleted the last element of the playlist, go back to the first
currentPlaylistIndex = 0
}
updateCurrentItem()
}
true
} else {
false
}
} }
protected fun goBackToExplorer() { protected fun goBackToExplorer() {

View File

@ -12,10 +12,12 @@ import android.widget.Toast
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.Constants import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding
@ -105,17 +107,15 @@ class ImageViewer: FileViewerActivity() {
.keepFullScreen() .keepFullScreen()
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
createPlaylist() //be sure the playlist is created before deleting if there is only one image lifecycleScope.launch {
if (encryptedVolume.deleteFile(filePath)) { if (deleteCurrentFile()) {
playlistNext(true) if (playlist.size == 0) { // no more image left
refreshPlaylist()
if (mappedPlaylist.size == 0) { //deleted all images of the playlist
goBackToExplorer() goBackToExplorer()
} else { } else {
loadImage(true) loadImage(true)
} }
} else { } else {
CustomAlertDialogBuilder(this, theme) CustomAlertDialogBuilder(this@ImageViewer, theme)
.keepFullScreen() .keepFullScreen()
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, fileName)) .setMessage(getString(R.string.remove_failed, fileName))
@ -123,6 +123,7 @@ class ImageViewer: FileViewerActivity() {
.show() .show()
} }
} }
}
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.setMessage(getString(R.string.single_delete_confirm, fileName)) .setMessage(getString(R.string.single_delete_confirm, fileName))
.show() .show()
@ -199,6 +200,7 @@ class ImageViewer: FileViewerActivity() {
} }
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false) { private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false) {
lifecycleScope.launch {
playlistNext(deltaX < 0) playlistNext(deltaX < 0)
loadImage(true) loadImage(true)
if (slideshowActive) { if (slideshowActive) {
@ -208,6 +210,7 @@ class ImageViewer: FileViewerActivity() {
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY) handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
} }
} }
}
private fun stopSlideshow(){ private fun stopSlideshow(){
slideshowActive = false slideshowActive = false

View File

@ -2,6 +2,7 @@ package sushi.hardcore.droidfs.file_viewers
import android.view.WindowManager import android.view.WindowManager
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
@ -11,6 +12,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.DefaultExtractorsFactory
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.Constants import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
@ -39,12 +41,16 @@ abstract class MediaPlayer: FileViewerActivity() {
private fun initializePlayer(){ private fun initializePlayer(){
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build() player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
bindPlayer(player) bindPlayer(player)
player.addMediaSource(createMediaSource(filePath))
lifecycleScope.launch {
createPlaylist() createPlaylist()
for (e in mappedPlaylist) { playlist.forEachIndexed { index, e ->
player.addMediaSource(createMediaSource(e.fullPath)) if (index != currentPlaylistIndex) {
player.addMediaSource(index, createMediaSource(e.fullPath))
}
}
} }
player.repeatMode = Player.REPEAT_MODE_ALL player.repeatMode = Player.REPEAT_MODE_ALL
player.seekToDefaultPosition(currentPlaylistIndex)
player.playWhenReady = true player.playWhenReady = true
player.addListener(object : Player.Listener{ player.addListener(object : Player.Listener{
override fun onVideoSizeChanged(videoSize: VideoSize) { override fun onVideoSizeChanged(videoSize: VideoSize) {
@ -67,11 +73,13 @@ abstract class MediaPlayer: FileViewerActivity() {
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (player.repeatMode != Player.REPEAT_MODE_ONE) { if (player.repeatMode != Player.REPEAT_MODE_ONE && currentPlaylistIndex != -1) {
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % mappedPlaylist.size) lifecycleScope.launch {
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % player.mediaItemCount)
refreshFileName() refreshFileName()
} }
} }
}
}) })
player.prepare() player.prepare()
} }

View File

@ -28,7 +28,7 @@ object AndroidUtils {
/** /**
* A [Manifest.permission.POST_NOTIFICATIONS] permission helper. * A [Manifest.permission.POST_NOTIFICATIONS] permission helper.
* *
* Must be initialized before [Activity.onCreate]. * Must be initialized before [Activity.onCreate] finishes.
*/ */
class NotificationPermissionHelper<out A: AppCompatActivity>(val activity: A) { class NotificationPermissionHelper<out A: AppCompatActivity>(val activity: A) {
private var listener: ((Boolean) -> Unit)? = null private var listener: ((Boolean) -> Unit)? = null

View File

@ -100,42 +100,6 @@ object PathUtils {
return "Android/data/${context.packageName}/" return "Android/data/${context.packageName}/"
} }
private fun getExternalStoragePath(context: Context, name: String): String? {
for (dir in ContextCompat.getExternalFilesDirs(context, null)) {
Log.d(PATH_RESOLVER_TAG, "External dir: $dir")
if (Environment.isExternalStorageRemovable(dir)) {
Log.d(PATH_RESOLVER_TAG, "isExternalStorageRemovable")
val path = dir.path.split("/Android")[0]
if (File(path).name == name) {
return path
}
}
}
Log.d(PATH_RESOLVER_TAG, "getExternalFilesDirs failed")
// Don't risk to be killed by SELinux on newer Android versions
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
try {
val process = ProcessBuilder("mount").redirectErrorStream(true).start().apply { waitFor() }
process.inputStream.readBytes().decodeToString().split("\n").forEach { line ->
if (line.startsWith("/dev/block/vold")) {
Log.d(PATH_RESOLVER_TAG, "mount: $line")
val fields = line.split(" ")
if (fields.size >= 3) {
val path = fields[2]
if (File(path).name == name) {
return path
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
Log.d(PATH_RESOLVER_TAG, "mount processing failed")
}
return null
}
private fun getExternalStoragesPaths(context: Context): List<String> { private fun getExternalStoragesPaths(context: Context): List<String> {
val externalPaths: MutableList<String> = ArrayList() val externalPaths: MutableList<String> = ArrayList()
ContextCompat.getExternalFilesDirs(context, null).forEach { ContextCompat.getExternalFilesDirs(context, null).forEach {
@ -156,21 +120,17 @@ object PathUtils {
return false return false
} }
private const val PRIMARY_VOLUME_NAME = "primary"
fun getFullPathFromTreeUri(treeUri: Uri, context: Context): String? { fun getFullPathFromTreeUri(treeUri: Uri, context: Context): String? {
Log.d(PATH_RESOLVER_TAG, "treeUri: $treeUri")
if ("content".equals(treeUri.scheme, ignoreCase = true)) { if ("content".equals(treeUri.scheme, ignoreCase = true)) {
val vId = getVolumeIdFromTreeUri(treeUri) val docId = DocumentsContract.getTreeDocumentId(treeUri)
Log.d(PATH_RESOLVER_TAG, "Volume Id: $vId") Log.d(PATH_RESOLVER_TAG, "Document Id: $docId")
var volumePath = getVolumePath(vId ?: return null, context) val split: Array<String?> = docId.split(":").toTypedArray()
val volumeId = if (split.isNotEmpty()) split[0] else null
Log.d(PATH_RESOLVER_TAG, "Volume Id: $volumeId")
val volumePath = getVolumePath(volumeId ?: return null, context)
Log.d(PATH_RESOLVER_TAG, "Volume Path: $volumePath") Log.d(PATH_RESOLVER_TAG, "Volume Path: $volumePath")
if (volumePath == null) { val documentPath = if (split.size >= 2 && split[1] != null) split[1]!! else File.separator
volumePath = if (vId == "primary") {
Environment.getExternalStorageDirectory().path
} else {
getExternalStoragePath(context, vId) ?: "/storage/$vId"
}
}
val documentPath = getDocumentPathFromTreeUri(treeUri)!!
Log.d(PATH_RESOLVER_TAG, "Document Path: $documentPath") Log.d(PATH_RESOLVER_TAG, "Document Path: $documentPath")
return if (documentPath.isNotEmpty()) { return if (documentPath.isNotEmpty()) {
pathJoin(volumePath!!, documentPath) pathJoin(volumePath!!, documentPath)
@ -181,39 +141,92 @@ object PathUtils {
return null return null
} }
private const val PRIMARY_VOLUME_NAME = "primary"
private fun getVolumePath(volumeId: String, context: Context): String? { private fun getVolumePath(volumeId: String, context: Context): String? {
return try { if (volumeId == PRIMARY_VOLUME_NAME) {
val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager // easy case
return Environment.getExternalStorageDirectory().path
}
// external storage
// First strategy: StorageManager.getStorageVolumes()
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// API is public on Android 11 and higher
storageManager.storageVolumes.forEach { storage ->
Log.d(PATH_RESOLVER_TAG, "StorageVolume: ${storage.uuid} ${storage.directory}")
if (volumeId.contentEquals(storage.uuid, true)) {
storage.directory?.let {
return it.absolutePath
}
}
}
Log.d(PATH_RESOLVER_TAG, "StorageManager failed")
} else {
// Before Android 11, we try reflection
try {
val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume") val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList") val getVolumeList = storageManager.javaClass.getMethod("getVolumeList")
val getUuid = storageVolumeClazz.getMethod("getUuid") val getUuid = storageVolumeClazz.getMethod("getUuid")
val getPath = storageVolumeClazz.getMethod("getPath") val getPath = storageVolumeClazz.getMethod("getPath")
val isPrimary = storageVolumeClazz.getMethod("isPrimary") val result = getVolumeList.invoke(storageManager)
val result = getVolumeList.invoke(mStorageManager)
val length = java.lang.reflect.Array.getLength(result!!) val length = java.lang.reflect.Array.getLength(result!!)
for (i in 0 until length) { for (i in 0 until length) {
val storageVolumeElement = java.lang.reflect.Array.get(result, i) val storageVolumeElement = java.lang.reflect.Array.get(result, i)
val uuid = getUuid.invoke(storageVolumeElement) val uuid = getUuid.invoke(storageVolumeElement)
val primary = isPrimary.invoke(storageVolumeElement) as Boolean
if (primary && PRIMARY_VOLUME_NAME == volumeId) return getPath.invoke(storageVolumeElement) as String
if (uuid == volumeId) return getPath.invoke(storageVolumeElement) as String if (uuid == volumeId) return getPath.invoke(storageVolumeElement) as String
} }
null } catch (e: Exception) {
} catch (ex: Exception) { Log.d(PATH_RESOLVER_TAG, "StorageManager reflection failed")
null
} }
} }
// Second strategy: Context.getExternalFilesDirs()
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? { for (dir in ContextCompat.getExternalFilesDirs(context, null)) {
val docId = DocumentsContract.getTreeDocumentId(treeUri) Log.d(PATH_RESOLVER_TAG, "External dir: $dir")
val split = docId.split(":").toTypedArray() if (Environment.isExternalStorageRemovable(dir)) {
return if (split.isNotEmpty()) split[0] else null Log.d(PATH_RESOLVER_TAG, "isExternalStorageRemovable")
val path = dir.path.split("/Android")[0]
if (File(path).name == volumeId) {
return path
} }
}
private fun getDocumentPathFromTreeUri(treeUri: Uri): String? { }
val docId = DocumentsContract.getTreeDocumentId(treeUri) Log.d(PATH_RESOLVER_TAG, "getExternalFilesDirs failed")
val split: Array<String?> = docId.split(":").toTypedArray() // Third strategy: parsing the output of mount
return if (split.size >= 2 && split[1] != null) split[1] else File.separator // Don't risk to be killed by SELinux on newer Android versions
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
try {
val process = ProcessBuilder("mount").redirectErrorStream(true).start().apply { waitFor() }
process.inputStream.readBytes().decodeToString().split("\n").forEach { line ->
if (line.startsWith("/dev/block/vold")) {
Log.d(PATH_RESOLVER_TAG, "mount: $line")
val fields = line.split(" ")
if (fields.size >= 3) {
val path = fields[2]
if (File(path).name == volumeId) {
return path
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
Log.d(PATH_RESOLVER_TAG, "mount processing failed")
}
// Fourth strategy: guessing
val directories = listOf("/storage/", "/mnt/media_rw/").map { File(it + volumeId) }
listOf(File::canWrite, File::canRead, File::isDirectory).forEach { check ->
directories.find { dir ->
if (check(dir)) {
Log.d(PATH_RESOLVER_TAG, "$dir: ${check.name}")
true
} else {
false
}
}?.let { return it.path }
}
// Fifth strategy: fail
return null
} }
fun recursiveRemoveDirectory(rootDirectory: File): Boolean { fun recursiveRemoveDirectory(rootDirectory: File): Boolean {

View File

@ -0,0 +1,45 @@
package sushi.hardcore.droidfs.util
/**
* Minimal ring buffer implementation.
*/
class RingBuffer<T>(private val capacity: Int) {
private val buffer = arrayOfNulls<Any?>(capacity)
/**
* Position of the first cell.
*/
private var head = 0
/**
* Position of the next free (or to be overwritten) cell.
*/
private var tail = 0
var size = 0
private set
fun addLast(e: T) {
buffer[tail] = e
tail = (tail + 1) % capacity
if (size < capacity) {
size += 1
} else {
head = (head + 1) % capacity
}
}
@Suppress("UNCHECKED_CAST")
fun popFirst() = buffer[head].also {
head = (head + 1) % capacity
size -= 1
} as T
/**
* Empty the buffer and call [f] for each element.
*/
fun drain(f: (T) -> Unit) {
repeat(size) {
f(popFirst())
}
}
}

View File

@ -0,0 +1,111 @@
package sushi.hardcore.droidfs.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.appcompat.widget.AppCompatTextView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.util.RingBuffer
/**
* A [TextView] dropping first lines when appended too fast.
*
* The dropping rate depends on the size of the ring buffer (set by the
* [R.styleable.RingBufferTextView_updateMaxLines] attribute) and the UI update
* time. If the buffer becomes full before the UI finished to update, the first
* (oldest) lines are dropped such as only the latest appended lines in the buffer
* are going to be displayed.
*
* If the ring buffer never fills up completely, the content of the [TextView] can
* grow indefinitely.
*/
class RingBufferTextView: AppCompatTextView {
private var updateMaxLines = -1
private var averageLineLength = -1
/**
* Lines ring buffer of capacity [updateMaxLines].
*
* Must never be used without acquiring the [bufferLock] mutex.
*/
private val buffer by lazy {
RingBuffer<String>(updateMaxLines)
}
private val bufferLock = Mutex()
/**
* Channel used to notify the worker coroutine that a new line has
* been appended to the ring buffer. No data is sent through it.
*
* We use a buffered channel with a capacity of 1 to ensure that the worker
* can be notified that at least one update occurred while allowing the
* sender to never block.
*
* A greater capacity is not desired because the worker empties the buffer each time.
*/
private val channel = Channel<Unit>(1)
private val scope = CoroutineScope(Dispatchers.Default)
constructor(context: Context, attrs: AttributeSet): super(context, attrs) { init(context, attrs) }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int): super(context, attrs, defStyleAttr) { init(context, attrs) }
private fun init(context: Context, attrs: AttributeSet) {
with (context.obtainStyledAttributes(attrs, R.styleable.RingBufferTextView)) {
updateMaxLines = getInt(R.styleable.RingBufferTextView_updateMaxLines, -1)
averageLineLength = getInt(R.styleable.RingBufferTextView_averageLineLength, -1)
recycle()
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
scope.launch {
val text = StringBuilder(updateMaxLines*averageLineLength)
while (isActive) {
channel.receive()
val size: Int
bufferLock.withLock {
size = buffer.size
buffer.drain {
text.appendLine(it)
}
}
withContext(Dispatchers.Main) {
if (size >= updateMaxLines) {
// Buffer full. Lines could have been dropped so we replace the content.
setText(text.toString())
} else {
super.append(text.toString())
}
}
text.clear()
}
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
scope.cancel()
}
/**
* Append a line to the ring buffer and update the UI.
*
* If the buffer is full (when adding [R.styleable.RingBufferTextView_updateMaxLines]
* lines before the UI had time to update), the oldest line is overwritten.
*/
suspend fun append(line: String) {
bufferLock.withLock {
buffer.addLast(line)
}
channel.trySend(Unit)
}
}

View File

@ -196,6 +196,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1list_1dir(JNIEnv
jint sessionID, jstring jplain_dir) { jint sessionID, jstring jplain_dir) {
const char* plain_dir = (*env)->GetStringUTFChars(env, jplain_dir, NULL); const char* plain_dir = (*env)->GetStringUTFChars(env, jplain_dir, NULL);
const size_t plain_dir_len = strlen(plain_dir); const size_t plain_dir_len = strlen(plain_dir);
const char append_slash = plain_dir[plain_dir_len-1] != '/';
GoString go_plain_dir = {plain_dir, plain_dir_len}; GoString go_plain_dir = {plain_dir, plain_dir_len};
struct gcf_list_dir_return elements = gcf_list_dir(sessionID, go_plain_dir); struct gcf_list_dir_return elements = gcf_list_dir(sessionID, go_plain_dir);
@ -216,7 +217,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1list_1dir(JNIEnv
char* fullPath = malloc(sizeof(char) * (plain_dir_len + nameLen + 2)); char* fullPath = malloc(sizeof(char) * (plain_dir_len + nameLen + 2));
strcpy(fullPath, plain_dir); strcpy(fullPath, plain_dir);
if (plain_dir[-2] != '/') { if (append_slash) {
strcat(fullPath, "/"); strcat(fullPath, "/");
} }
strcat(fullPath, name); strcat(fullPath, name);

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@ -8,10 +9,12 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<TextView <sushi.hardcore.droidfs.widgets.RingBufferTextView
android:id="@+id/content" android:id="@+id/content"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:updateMaxLines="500"
app:averageLineLength="100"
android:textIsSelectable="true" android:textIsSelectable="true"
android:typeface="monospace" /> android:typeface="monospace" />

View File

@ -75,7 +75,7 @@
<string name="usf_fingerprint">Kennwort-Hash mit Fingerabdruck speichern können</string> <string name="usf_fingerprint">Kennwort-Hash mit Fingerabdruck speichern können</string>
<string name="usf_volume_management">Volumenverwaltung</string> <string name="usf_volume_management">Volumenverwaltung</string>
<string name="unsafe_features">Unsichere Funktionen</string> <string name="unsafe_features">Unsichere Funktionen</string>
<string name="manage_unsafe_features">Sichere Funktionen verwalten</string> <string name="manage_unsafe_features">Unsichere Funktionen verwalten</string>
<string name="manage_unsafe_features_summary">Aktivieren/Deaktivieren unsicherer Funktionen</string> <string name="manage_unsafe_features_summary">Aktivieren/Deaktivieren unsicherer Funktionen</string>
<string name="usf_home_warning_msg">DroidFS versucht, so sicher wie möglich zu sein. Sicherheit ist jedoch oft mit mangelndem Komfort verbunden. Aus diesem Grund bietet DroidFS zusätzliche unsichere Funktionen, die Sie je nach Bedarf aktivieren/deaktivieren können.\n\nWarnung: Diese Funktionen können UNSICHER sein. Verwenden Sie sie nicht, wenn Sie nicht genau wissen, was Sie tun. Es wird dringend empfohlen, die Dokumentation zu lesen, bevor Sie sie aktivieren.</string> <string name="usf_home_warning_msg">DroidFS versucht, so sicher wie möglich zu sein. Sicherheit ist jedoch oft mit mangelndem Komfort verbunden. Aus diesem Grund bietet DroidFS zusätzliche unsichere Funktionen, die Sie je nach Bedarf aktivieren/deaktivieren können.\n\nWarnung: Diese Funktionen können UNSICHER sein. Verwenden Sie sie nicht, wenn Sie nicht genau wissen, was Sie tun. Es wird dringend empfohlen, die Dokumentation zu lesen, bevor Sie sie aktivieren.</string>
<string name="see_unsafe_features">Siehe unsichere Funktionen</string> <string name="see_unsafe_features">Siehe unsichere Funktionen</string>

View File

@ -74,12 +74,11 @@
<string name="usf_screenshot">אפשר צילומי מסך</string> <string name="usf_screenshot">אפשר צילומי מסך</string>
<string name="usf_fingerprint">אפשר שימרת גיבוב של הסיסמא באמצעות טביעת אצבע</string> <string name="usf_fingerprint">אפשר שימרת גיבוב של הסיסמא באמצעות טביעת אצבע</string>
<string name="usf_volume_management">הגדרת אמצעי אחסון</string> <string name="usf_volume_management">הגדרת אמצעי אחסון</string>
<string name="usf_keep_open">השאר את האמצעי אחסון פתוח כשהאפליקציה רצה ברקע</string>
<string name="unsafe_features">פיצרים לא בטוחים</string> <string name="unsafe_features">פיצרים לא בטוחים</string>
<string name="manage_unsafe_features">נהל פיצרים לא בטוחים</string> <string name="manage_unsafe_features">נהל פיצרים לא בטוחים</string>
<string name="manage_unsafe_features_summary">אפשר/מנע פיצרים לא בטוחים</string> <string name="manage_unsafe_features_summary">אפשר/מנע פיצרים לא בטוחים</string>
<string name="usf_home_warning_msg">DroidFS מנסה להיות מאובטח ככל האפשר,עם זאת אבטחה כרוכה לעיתים קרובת בחוסר נוחות. זה הסיבה ש DroidFS מציע פיצרים לא בטוחים שאתה יכול להפעיל /להשבית בהתאם לצרכים שלך . <string name="usf_home_warning_msg">DroidFS מנסה להיות מאובטח ככל האפשר,עם זאת אבטחה כרוכה לעיתים קרובת בחוסר נוחות. זה הסיבה ש DroidFS מציע פיצרים לא בטוחים שאתה יכול להפעיל /להשבית בהתאם לצרכים שלך .\n\n אזהרה: פיצרים אלה יכולים להיות לא בטוחים. אל תשתמש בהם אלא אם כן אתה יודע בידיוק מה אתה עושה. מומלץ מאוד לקרוא את הקובץ דוקמנטציה לפני שמפעילים אותם.</string>
אזהרה: פיצרים אלה יכולים להיות לא בטוחים. אל תשתמש בהם אלא אם כן אתה יודע בידיוק מה אתה עושה. מומלץ מאוד לקרוא את הקובץ דוקמנטציה לפני שמפעילים אותם.</string>
<string name="see_unsafe_features">הראה פיצרים לא בטוחים</string> <string name="see_unsafe_features">הראה פיצרים לא בטוחים</string>
<string name="open_as">פתח כ</string> <string name="open_as">פתח כ</string>
<string name="image">תמונה</string> <string name="image">תמונה</string>
@ -198,7 +197,7 @@
<string name="password_confirmation_label">חזור שנית על הסיסמא:</string> <string name="password_confirmation_label">חזור שנית על הסיסמא:</string>
<string name="password_confirmation_hint">אישור סיסמא</string> <string name="password_confirmation_hint">אישור סיסמא</string>
<string name="password_hash_saved">הגיבוב של הסיסמא נשמר</string> <string name="password_hash_saved">הגיבוב של הסיסמא נשמר</string>
<string name="no_volumes_text">אין אמצעי אחסון שמורים, הוסף ע\"י לחציה על הסימן +</string> <string name="no_volumes_text">אין אמצעי אחסון שמורים, הוסף ע"י לחציה על הסימן +</string>
<string name="fingerprint_error_msg">לא ניתן להשתמש באימות טביעת אצבע: %s.</string> <string name="fingerprint_error_msg">לא ניתן להשתמש באימות טביעת אצבע: %s.</string>
<string name="keyguard_not_secure">מגן מקשים לא מאובטח</string> <string name="keyguard_not_secure">מגן מקשים לא מאובטח</string>
<string name="no_hardware">לא נמצאה חומרה מתאימה</string> <string name="no_hardware">לא נמצאה חומרה מתאימה</string>
@ -232,7 +231,7 @@
<string name="multiple_folders">%d תיקיות</string> <string name="multiple_folders">%d תיקיות</string>
<string name="default_open">פתח אמצעי אחסון זה בעת הפעלת היישום</string> <string name="default_open">פתח אמצעי אחסון זה בעת הפעלת היישום</string>
<string name="remove_default_open">אל תפתח כברירת מחדל</string> <string name="remove_default_open">אל תפתח כברירת מחדל</string>
<string name="elements_selected" formatted="false">%d/%d נבחרו</string> <string name="elements_selected">%d/%d נבחרו</string>
<string name="pin_passwords_title">פריסת מקלדת מספרים</string> <string name="pin_passwords_title">פריסת מקלדת מספרים</string>
<string name="pin_passwords_summary">שימוש בפריסת מקלדת מספרים בעת הזנת סיסמאות אמצעי אחסון</string> <string name="pin_passwords_summary">שימוש בפריסת מקלדת מספרים בעת הזנת סיסמאות אמצעי אחסון</string>
<string name="volume_type_label">סוג אמצעי אחסון:</string> <string name="volume_type_label">סוג אמצעי אחסון:</string>
@ -280,18 +279,4 @@
<string name="debug">דיבאג</string> <string name="debug">דיבאג</string>
<string name="logcat_title">DroidFS Logcat</string> <string name="logcat_title">DroidFS Logcat</string>
<string name="logcat_saved">Logcat נשמר</string> <string name="logcat_saved">Logcat נשמר</string>
<string name="later">אחר כך</string>
<string name="notification_denied_msg">הרשאת ההתראות נדחתה. פעולות קובץ ברקע לא יהיו נראות. אתה יכול לשנות זאת בהגדרות ההרשאות של האפליקציה.</string>
<string name="keep_alive_notification_title">שומר על האפליקציה שתרוץ ברקע</string>
<string name="keep_alive_notification_text">אמצעי אחסון אחד או יותר נשמרים פתוחים.</string>
<string name="close_all">סגור הכל</string>
<string name="usf_background">בטל נעילה אוטומטית לאמצעי אחסון</string>
<string name="usf_background_summary">אל תנעל את האמצעי אחסון כשהאפליקציה רצה ברקע</string>
<string name="usf_keep_open">השאר את האמצעי אחסון פתוח כשהאפליקציה רצה ברקע</string>
<string name="usf_keep_open_summary">השאר את האפליקציה רצה תמיד ברקע כדי לשמור על אמצעי אחסון פתוחים</string>
<string name="gocryptfs_details">מהיר, אבל לא מסתיר את גדול הקבצים ומבנה התיקיות.</string>
<string name="cryfs_details">איטי יותר, אבל מגן על מטא נתונים (גודל קבצים ומבנה תיקיות)
ומונע מתקפה של החלפת קבצים.</string>
<string name="or">או</string>
<string name="enter_volume_path">הכנס מתיב תיקייה</string>
</resources> </resources>

View File

@ -2,4 +2,8 @@
<resources> <resources>
<attr name="buttonBackgroundColor" format="color"/> <attr name="buttonBackgroundColor" format="color"/>
<attr name="infoBarBackgroundColor" format="color"/> <attr name="infoBarBackgroundColor" format="color"/>
<declare-styleable name="RingBufferTextView">
<attr name="updateMaxLines" format="integer"/>
<attr name="averageLineLength" format="integer"/>
</declare-styleable>
</resources> </resources>