Compare commits

...

12 Commits

16 changed files with 367 additions and 165 deletions

View File

@ -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,7 +88,9 @@ class EncryptedFileProvider(context: Context) {
}
override fun free() {
Wiper.wipe(file)
GlobalScope.launch(Dispatchers.IO) {
Wiper.wipe(file)
}
}
}
@ -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 {

View File

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

View File

@ -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,11 +73,13 @@ class LogcatActivity: BaseActivity() {
private fun saveTo(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
BufferedWriter(OutputStreamWriter(contentResolver.openOutputStream(uri))).use {
it.write(binding.content.text.toString())
}
launch(Dispatchers.Main) {
Toast.makeText(this@LogcatActivity, R.string.logcat_saved, Toast.LENGTH_SHORT).show()
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()
}
}
}
}

View File

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

View File

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

View File

@ -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
}
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
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 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
}
x
(currentPlaylistIndex - 1).mod(playlist.size)
}
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() {

View File

@ -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,22 +107,21 @@ 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
goBackToExplorer()
lifecycleScope.launch {
if (deleteCurrentFile()) {
if (playlist.size == 0) { // no more image left
goBackToExplorer()
} else {
loadImage(true)
}
} else {
loadImage(true)
CustomAlertDialogBuilder(this@ImageViewer, theme)
.keepFullScreen()
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, fileName))
.setPositiveButton(R.string.ok, null)
.show()
}
} else {
CustomAlertDialogBuilder(this, theme)
.keepFullScreen()
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, fileName))
.setPositiveButton(R.string.ok, null)
.show()
}
}
.setNegativeButton(R.string.cancel, null)
@ -198,14 +199,16 @@ class ImageViewer: FileViewerActivity() {
rotateImage()
}
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false){
playlistNext(deltaX < 0)
loadImage(true)
if (slideshowActive) {
if (!slideshowSwipe) { //reset slideshow delay if user swipes
handler.removeCallbacks(slideshowNext)
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false) {
lifecycleScope.launch {
playlistNext(deltaX < 0)
loadImage(true)
if (slideshowActive) {
if (!slideshowSwipe) { // reset slideshow delay if user swipes
handler.removeCallbacks(slideshowNext)
}
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
}
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
}
}

View File

@ -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)
createPlaylist()
for (e in mappedPlaylist) {
player.addMediaSource(createMediaSource(e.fullPath))
player.addMediaSource(createMediaSource(filePath))
lifecycleScope.launch {
createPlaylist()
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,9 +73,11 @@ 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)
refreshFileName()
if (player.repeatMode != Player.REPEAT_MODE_ONE && currentPlaylistIndex != -1) {
lifecycleScope.launch {
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % player.mediaItemCount)
refreshFileName()
}
}
}
})

View File

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

View File

@ -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
val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList")
val getUuid = storageVolumeClazz.getMethod("getUuid")
val getPath = storageVolumeClazz.getMethod("getPath")
val isPrimary = storageVolumeClazz.getMethod("isPrimary")
val result = getVolumeList.invoke(mStorageManager)
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
if (volumeId == PRIMARY_VOLUME_NAME) {
// easy case
return Environment.getExternalStorageDirectory().path
}
}
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
val docId = DocumentsContract.getTreeDocumentId(treeUri)
val split = docId.split(":").toTypedArray()
return if (split.isNotEmpty()) split[0] else null
}
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
// 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 = storageManager.javaClass.getMethod("getVolumeList")
val getUuid = storageVolumeClazz.getMethod("getUuid")
val getPath = storageVolumeClazz.getMethod("getPath")
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)
if (uuid == volumeId) return getPath.invoke(storageVolumeElement) as String
}
} catch (e: Exception) {
Log.d(PATH_RESOLVER_TAG, "StorageManager reflection failed")
}
}
// 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
}
}
}
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 {

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) {
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);

View File

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

View File

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

View File

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