forked from hardcoresushi/DroidFS
Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
4d6b043a8a | |||
ae0e23acc9 | |||
|
d72cc857e2 | ||
82dda95211 | |||
40bed2db21 | |||
f901495e41 | |||
07f5f8b5d9 | |||
2d5f5a82c9 | |||
b477272d65 | |||
88bd746359 | |||
9872cab7c2 | |||
4aa211bca4 |
@ -9,6 +9,8 @@ import android.system.Os
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.Compat
|
||||
@ -86,9 +88,11 @@ class EncryptedFileProvider(context: Context) {
|
||||
}
|
||||
|
||||
override fun free() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Wiper.wipe(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExportedMemFile private constructor(path: String, private val file: MemFile) :
|
||||
ExportedFile(path) {
|
||||
@ -160,8 +164,10 @@ class EncryptedFileProvider(context: Context) {
|
||||
exportedFile: ExportedFile,
|
||||
encryptedVolume: EncryptedVolume,
|
||||
): Boolean {
|
||||
val fd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false).fileDescriptor
|
||||
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(fd))
|
||||
val pfd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false)
|
||||
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(pfd.fileDescriptor)).also {
|
||||
pfd.close()
|
||||
}
|
||||
}
|
||||
|
||||
enum class Error {
|
||||
|
@ -4,8 +4,8 @@ import java.io.File
|
||||
|
||||
object FileTypes {
|
||||
private val FILE_EXTENSIONS = mapOf(
|
||||
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")),
|
||||
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
|
||||
Pair("image", listOf("png", "jpg", "jpeg", "gif", "avif", "webp", "bmp", "heic")),
|
||||
Pair("video", listOf("mp4", "webm", "mkv", "mov", "m4v")),
|
||||
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac", "opus")),
|
||||
Pair("pdf", listOf("pdf")),
|
||||
Pair("text", listOf(
|
||||
|
@ -11,10 +11,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.databinding.ActivityLogcatBinding
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.InterruptedIOException
|
||||
import java.io.OutputStreamWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@ -40,12 +38,10 @@ class LogcatActivity: BaseActivity() {
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
BufferedReader(InputStreamReader(Runtime.getRuntime().exec("logcat").also {
|
||||
BufferedReader(InputStreamReader(ProcessBuilder("logcat", "-T", "500").start().also {
|
||||
process = it
|
||||
}.inputStream)).forEachLine {
|
||||
binding.content.post {
|
||||
binding.content.append("$it\n")
|
||||
}
|
||||
}.inputStream)).lineSequence().forEach {
|
||||
binding.content.append(it)
|
||||
}
|
||||
} catch (_: InterruptedIOException) {}
|
||||
}
|
||||
@ -77,8 +73,9 @@ class LogcatActivity: BaseActivity() {
|
||||
|
||||
private fun saveTo(uri: Uri) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
BufferedWriter(OutputStreamWriter(contentResolver.openOutputStream(uri))).use {
|
||||
it.write(binding.content.text.toString())
|
||||
contentResolver.openOutputStream(uri)?.use { output ->
|
||||
Runtime.getRuntime().exec("logcat -d").inputStream.use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
launch(Dispatchers.Main) {
|
||||
Toast.makeText(this@LogcatActivity, R.string.logcat_saved, Toast.LENGTH_SHORT).show()
|
||||
@ -86,3 +83,4 @@ class LogcatActivity: BaseActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -328,8 +328,8 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
activityScope.launch {
|
||||
onTaskResult(
|
||||
fileOperationService.moveElements(volumeId, toMove, toClean),
|
||||
R.string.move_success,
|
||||
R.string.move_failed,
|
||||
R.string.move_success,
|
||||
)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
|
@ -104,7 +104,9 @@ class FileOperationService : Service() {
|
||||
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
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(
|
||||
|
@ -14,6 +14,8 @@ import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.BaseActivity
|
||||
import sushi.hardcore.droidfs.FileTypes
|
||||
@ -21,7 +23,6 @@ import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.finishOnClose
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
@ -32,9 +33,8 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
private lateinit var originalParentPath: String
|
||||
private lateinit var windowInsetsController: WindowInsetsControllerCompat
|
||||
private var windowTypeMask = 0
|
||||
private var foldersFirst = true
|
||||
private var wasMapped = false
|
||||
protected val mappedPlaylist = mutableListOf<ExplorerElement>()
|
||||
protected val playlist = mutableListOf<ExplorerElement>()
|
||||
private val playlistMutex = Mutex()
|
||||
protected var currentPlaylistIndex = -1
|
||||
private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
|
||||
|
||||
@ -46,7 +46,6 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
intent.getIntExtra("volumeId", -1)
|
||||
)!!
|
||||
finishOnClose(encryptedVolume)
|
||||
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
|
||||
windowTypeMask = typeMask
|
||||
@ -131,48 +130,57 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
protected fun createPlaylist() {
|
||||
if (!wasMapped){
|
||||
encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements ->
|
||||
for (e in elements) {
|
||||
if (e.isRegularFile) {
|
||||
if (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
|
||||
mappedPlaylist.add(e)
|
||||
}
|
||||
}
|
||||
protected suspend fun createPlaylist() {
|
||||
playlistMutex.withLock {
|
||||
if (currentPlaylistIndex != -1) {
|
||||
// playlist already initialized
|
||||
return
|
||||
}
|
||||
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"
|
||||
ExplorerElement.sortBy(sortOrder, foldersFirst, mappedPlaylist)
|
||||
//find current index
|
||||
for ((i, e) in mappedPlaylist.withIndex()){
|
||||
if (filePath == e.fullPath){
|
||||
currentPlaylistIndex = i
|
||||
break
|
||||
val foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||
ExplorerElement.sortBy(sortOrder, foldersFirst, playlist)
|
||||
currentPlaylistIndex = playlist.indexOfFirst { it.fullPath == filePath }
|
||||
}
|
||||
}
|
||||
wasMapped = true
|
||||
}
|
||||
}
|
||||
|
||||
protected fun playlistNext(forward: Boolean) {
|
||||
private fun updateCurrentItem() {
|
||||
filePath = playlist[currentPlaylistIndex].fullPath
|
||||
}
|
||||
|
||||
protected suspend fun playlistNext(forward: Boolean) {
|
||||
createPlaylist()
|
||||
currentPlaylistIndex = if (forward) {
|
||||
(currentPlaylistIndex+1)%mappedPlaylist.size
|
||||
(currentPlaylistIndex + 1).mod(playlist.size)
|
||||
} else {
|
||||
var x = (currentPlaylistIndex-1)%mappedPlaylist.size
|
||||
if (x < 0) {
|
||||
x += mappedPlaylist.size
|
||||
(currentPlaylistIndex - 1).mod(playlist.size)
|
||||
}
|
||||
x
|
||||
}
|
||||
filePath = mappedPlaylist[currentPlaylistIndex].fullPath
|
||||
updateCurrentItem()
|
||||
}
|
||||
|
||||
protected fun refreshPlaylist() {
|
||||
mappedPlaylist.clear()
|
||||
wasMapped = false
|
||||
createPlaylist()
|
||||
protected suspend fun deleteCurrentFile(): Boolean {
|
||||
createPlaylist() // ensure we know the current position in the playlist
|
||||
return if (encryptedVolume.deleteFile(filePath)) {
|
||||
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() {
|
||||
|
@ -12,10 +12,12 @@ import android.widget.Toast
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding
|
||||
@ -105,17 +107,15 @@ class ImageViewer: FileViewerActivity() {
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.warning)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
createPlaylist() //be sure the playlist is created before deleting if there is only one image
|
||||
if (encryptedVolume.deleteFile(filePath)) {
|
||||
playlistNext(true)
|
||||
refreshPlaylist()
|
||||
if (mappedPlaylist.size == 0) { //deleted all images of the playlist
|
||||
lifecycleScope.launch {
|
||||
if (deleteCurrentFile()) {
|
||||
if (playlist.size == 0) { // no more image left
|
||||
goBackToExplorer()
|
||||
} else {
|
||||
loadImage(true)
|
||||
}
|
||||
} else {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
CustomAlertDialogBuilder(this@ImageViewer, theme)
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.remove_failed, fileName))
|
||||
@ -123,6 +123,7 @@ class ImageViewer: FileViewerActivity() {
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setMessage(getString(R.string.single_delete_confirm, fileName))
|
||||
.show()
|
||||
@ -199,6 +200,7 @@ class ImageViewer: FileViewerActivity() {
|
||||
}
|
||||
|
||||
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false) {
|
||||
lifecycleScope.launch {
|
||||
playlistNext(deltaX < 0)
|
||||
loadImage(true)
|
||||
if (slideshowActive) {
|
||||
@ -208,6 +210,7 @@ class ImageViewer: FileViewerActivity() {
|
||||
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSlideshow(){
|
||||
slideshowActive = false
|
||||
|
@ -2,6 +2,7 @@ package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
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.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
@ -39,12 +41,16 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
private fun initializePlayer(){
|
||||
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
|
||||
bindPlayer(player)
|
||||
player.addMediaSource(createMediaSource(filePath))
|
||||
lifecycleScope.launch {
|
||||
createPlaylist()
|
||||
for (e in mappedPlaylist) {
|
||||
player.addMediaSource(createMediaSource(e.fullPath))
|
||||
playlist.forEachIndexed { index, e ->
|
||||
if (index != currentPlaylistIndex) {
|
||||
player.addMediaSource(index, createMediaSource(e.fullPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
player.repeatMode = Player.REPEAT_MODE_ALL
|
||||
player.seekToDefaultPosition(currentPlaylistIndex)
|
||||
player.playWhenReady = true
|
||||
player.addListener(object : Player.Listener{
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
@ -67,11 +73,13 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
if (player.repeatMode != Player.REPEAT_MODE_ONE) {
|
||||
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % mappedPlaylist.size)
|
||||
if (player.repeatMode != Player.REPEAT_MODE_ONE && currentPlaylistIndex != -1) {
|
||||
lifecycleScope.launch {
|
||||
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % player.mediaItemCount)
|
||||
refreshFileName()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
player.prepare()
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ object AndroidUtils {
|
||||
/**
|
||||
* 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) {
|
||||
private var listener: ((Boolean) -> Unit)? = null
|
||||
|
@ -100,42 +100,6 @@ object PathUtils {
|
||||
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> {
|
||||
val externalPaths: MutableList<String> = ArrayList()
|
||||
ContextCompat.getExternalFilesDirs(context, null).forEach {
|
||||
@ -156,21 +120,17 @@ object PathUtils {
|
||||
return false
|
||||
}
|
||||
|
||||
private const val PRIMARY_VOLUME_NAME = "primary"
|
||||
fun getFullPathFromTreeUri(treeUri: Uri, context: Context): String? {
|
||||
Log.d(PATH_RESOLVER_TAG, "treeUri: $treeUri")
|
||||
if ("content".equals(treeUri.scheme, ignoreCase = true)) {
|
||||
val vId = getVolumeIdFromTreeUri(treeUri)
|
||||
Log.d(PATH_RESOLVER_TAG, "Volume Id: $vId")
|
||||
var volumePath = getVolumePath(vId ?: return null, context)
|
||||
val docId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
Log.d(PATH_RESOLVER_TAG, "Document Id: $docId")
|
||||
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")
|
||||
if (volumePath == null) {
|
||||
volumePath = if (vId == "primary") {
|
||||
Environment.getExternalStorageDirectory().path
|
||||
} else {
|
||||
getExternalStoragePath(context, vId) ?: "/storage/$vId"
|
||||
}
|
||||
}
|
||||
val documentPath = getDocumentPathFromTreeUri(treeUri)!!
|
||||
val documentPath = if (split.size >= 2 && split[1] != null) split[1]!! else File.separator
|
||||
Log.d(PATH_RESOLVER_TAG, "Document Path: $documentPath")
|
||||
return if (documentPath.isNotEmpty()) {
|
||||
pathJoin(volumePath!!, documentPath)
|
||||
@ -181,39 +141,92 @@ object PathUtils {
|
||||
return null
|
||||
}
|
||||
|
||||
private const val PRIMARY_VOLUME_NAME = "primary"
|
||||
private fun getVolumePath(volumeId: String, context: Context): String? {
|
||||
return try {
|
||||
val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
if (volumeId == PRIMARY_VOLUME_NAME) {
|
||||
// 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 getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList")
|
||||
val getVolumeList = storageManager.javaClass.getMethod("getVolumeList")
|
||||
val getUuid = storageVolumeClazz.getMethod("getUuid")
|
||||
val getPath = storageVolumeClazz.getMethod("getPath")
|
||||
val isPrimary = storageVolumeClazz.getMethod("isPrimary")
|
||||
val result = getVolumeList.invoke(mStorageManager)
|
||||
val result = getVolumeList.invoke(storageManager)
|
||||
val length = java.lang.reflect.Array.getLength(result!!)
|
||||
for (i in 0 until length) {
|
||||
val storageVolumeElement = java.lang.reflect.Array.get(result, i)
|
||||
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
|
||||
}
|
||||
null
|
||||
} catch (ex: Exception) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.d(PATH_RESOLVER_TAG, "StorageManager reflection failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
|
||||
val docId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val split = docId.split(":").toTypedArray()
|
||||
return if (split.isNotEmpty()) split[0] else null
|
||||
// Second strategy: Context.getExternalFilesDirs()
|
||||
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 == volumeId) {
|
||||
return path
|
||||
}
|
||||
|
||||
private fun getDocumentPathFromTreeUri(treeUri: Uri): String? {
|
||||
val docId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val split: Array<String?> = docId.split(":").toTypedArray()
|
||||
return if (split.size >= 2 && split[1] != null) split[1] else File.separator
|
||||
}
|
||||
}
|
||||
Log.d(PATH_RESOLVER_TAG, "getExternalFilesDirs failed")
|
||||
// Third strategy: parsing the output of mount
|
||||
// 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 {
|
||||
|
45
app/src/main/java/sushi/hardcore/droidfs/util/RingBuffer.kt
Normal file
45
app/src/main/java/sushi/hardcore/droidfs/util/RingBuffer.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -196,6 +196,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1list_1dir(JNIEnv
|
||||
jint sessionID, jstring jplain_dir) {
|
||||
const char* plain_dir = (*env)->GetStringUTFChars(env, jplain_dir, NULL);
|
||||
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};
|
||||
|
||||
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));
|
||||
strcpy(fullPath, plain_dir);
|
||||
if (plain_dir[-2] != '/') {
|
||||
if (append_slash) {
|
||||
strcat(fullPath, "/");
|
||||
}
|
||||
strcat(fullPath, name);
|
||||
|
@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
@ -8,10 +9,12 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
<sushi.hardcore.droidfs.widgets.RingBufferTextView
|
||||
android:id="@+id/content"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:updateMaxLines="500"
|
||||
app:averageLineLength="100"
|
||||
android:textIsSelectable="true"
|
||||
android:typeface="monospace" />
|
||||
|
||||
|
@ -75,7 +75,7 @@
|
||||
<string name="usf_fingerprint">Kennwort-Hash mit Fingerabdruck speichern können</string>
|
||||
<string name="usf_volume_management">Volumenverwaltung</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="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>
|
||||
|
@ -2,4 +2,8 @@
|
||||
<resources>
|
||||
<attr name="buttonBackgroundColor" format="color"/>
|
||||
<attr name="infoBarBackgroundColor" format="color"/>
|
||||
<declare-styleable name="RingBufferTextView">
|
||||
<attr name="updateMaxLines" format="integer"/>
|
||||
<attr name="averageLineLength" format="integer"/>
|
||||
</declare-styleable>
|
||||
</resources>
|
Loading…
Reference in New Issue
Block a user