fixed download logs text not being highlightable, fixed app not deleting some cached files, filed filenames being too short, fixed sometimes error output not being shown in terminal output, added keyboard focus when adding template in the terminal, made the loading of video player faster when you are cutting it

This commit is contained in:
deniscerri 2023-04-03 22:12:30 +02:00
parent daac3fd241
commit f88bfab70e
No known key found for this signature in database
GPG key ID: 95C43D517D830350
12 changed files with 92 additions and 106 deletions

View file

@ -94,7 +94,7 @@ class HistoryAdapter(onItemClickListener: OnItemClickListener, activity: Activit
// System.currentTimeMillis(), // System.currentTimeMillis(),
// DateUtils.MINUTE_IN_MILLIS // DateUtils.MINUTE_IN_MILLIS
// ) // )
datetime.text = SimpleDateFormat(DateFormat.getBestDateTimePattern(Locale.getDefault(), "ddMMMyyyy - HHmm"), Locale.getDefault()).format(item.time * 1000L) datetime.text = SimpleDateFormat(android.text.format.DateFormat.getBestDateTimePattern(Locale.getDefault(), "ddMMMyyyy - HHmm"), Locale.getDefault()).format(item.time * 1000L)
// BUTTON ---------------------------------- // BUTTON ----------------------------------
val btn = card.findViewById<MaterialButton>(R.id.downloads_download_button_type) val btn = card.findViewById<MaterialButton>(R.id.downloads_download_button_type)

View file

@ -24,6 +24,7 @@ import com.deniscerri.ytdlnis.work.DownloadWorker
import com.google.gson.Gson import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.sql.Date import java.sql.Date
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -310,7 +311,9 @@ class DownloadViewModel(application: Application) : AndroidViewModel(application
).enqueue() ).enqueue()
} }
items.forEach { items.forEach {
it.id = repository.checkIfReDownloadingErroredOrCancelled(it) it.id = withContext(Dispatchers.IO){
repository.checkIfReDownloadingErroredOrCancelled(it)
}
if (it.id != 0L){ if (it.id != 0L){
repository.delete(it) repository.delete(it)
} }

View file

@ -107,21 +107,20 @@ class CutVideoBottomSheetDialog(private val item: DownloadItem, private val list
} }
if (url.isBlank()) throw Exception("No Streaming URL found!") if (url.isBlank()) throw Exception("No Streaming URL found!")
Log.e("aa", url.split("\n")[0])
Log.e("aa", url.split("\n")[1])
val urls = url.split("\n") // val urls = url.split("\n")
if (urls.size == 2){ // if (urls.size == 2){
val audioSource: MediaSource = // val audioSource: MediaSource =
DefaultMediaSourceFactory(requireContext()) // DefaultMediaSourceFactory(requireContext())
.createMediaSource(fromUri(Uri.parse(urls[0]))) // .createMediaSource(fromUri(Uri.parse(urls[0])))
val videoSource: MediaSource = // val videoSource: MediaSource =
DefaultMediaSourceFactory(requireContext()) // DefaultMediaSourceFactory(requireContext())
.createMediaSource(fromUri(Uri.parse(urls[1]))) // .createMediaSource(fromUri(Uri.parse(urls[1])))
(player as ExoPlayer).setMediaSource(MergingMediaSource(videoSource, audioSource)) // (player as ExoPlayer).setMediaSource(MergingMediaSource(videoSource, audioSource))
}else{ // }else{
player.addMediaItem(fromUri(Uri.parse(urls[0]))) // player.addMediaItem(fromUri(Uri.parse(urls[0])))
} // }
player.addMediaItem(fromUri(Uri.parse(url)))
progress.visibility = View.GONE progress.visibility = View.GONE

View file

@ -9,6 +9,7 @@ import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.Window import android.view.Window
import android.view.inputmethod.InputMethodManager
import android.widget.EditText import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ScrollView import android.widget.ScrollView
@ -17,11 +18,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.work.Data import androidx.work.*
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.deniscerri.ytdlnis.R import com.deniscerri.ytdlnis.R
import com.deniscerri.ytdlnis.database.viewmodel.CommandTemplateViewModel import com.deniscerri.ytdlnis.database.viewmodel.CommandTemplateViewModel
import com.deniscerri.ytdlnis.util.NotificationUtil import com.deniscerri.ytdlnis.util.NotificationUtil
@ -109,9 +106,7 @@ class TerminalActivity : AppCompatActivity() {
input!!.requestFocus() input!!.requestFocus()
hideCancelFab() hideCancelFab()
} }
val line = work.progress.getString("output") ?: return@observe val line = work.progress.getString("output") ?: (work.outputData.getString("output") ?: return@observe)
val id = work.progress.getInt("id", 0)
if(id == 0) return@observe
runOnUiThread { runOnUiThread {
try { try {
output!!.text = "${output!!.text}\n$line" output!!.text = "${output!!.text}\n$line"
@ -168,6 +163,11 @@ class TerminalActivity : AppCompatActivity() {
item.setOnClickListener { item.setOnClickListener {
input!!.text.insert(input!!.selectionStart, template.content + " ") input!!.text.insert(input!!.selectionStart, template.content + " ")
bottomSheet.cancel() bottomSheet.cancel()
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
input!!.postDelayed({
input!!.requestFocus()
imm.showSoftInput(input!!, 0)
}, 200)
} }
linearLayout.addView(item) linearLayout.addView(item)
} }

View file

@ -34,6 +34,7 @@ class DownloadLogActivity : AppCompatActivity() {
} }
content = findViewById(R.id.content) content = findViewById(R.id.content)
content.setTextIsSelectable(true)
contentScrollView = findViewById(R.id.content_scrollview) contentScrollView = findViewById(R.id.content_scrollview)
copyLog = findViewById(R.id.copy_log) copyLog = findViewById(R.id.copy_log)

View file

@ -28,6 +28,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDL
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File
import java.util.* import java.util.*
@ -135,15 +136,11 @@ class SettingsFragment : PreferenceFragmentCompat() {
version!!.summary = BuildConfig.VERSION_NAME version!!.summary = BuildConfig.VERSION_NAME
val values = resources.getStringArray(R.array.language_values) val values = resources.getStringArray(R.array.language_values)
if (Build.VERSION.SDK_INT >= 33 || Build.VERSION.SDK_INT < 24){ val entries = mutableListOf<String>()
language!!.isVisible = false values.forEach {
}else{ entries.add(Locale(it).getDisplayName(Locale(it)))
val entries = mutableListOf<String>()
values.forEach {
entries.add(Locale(it).getDisplayName(Locale(it)))
}
language!!.entries = entries.toTypedArray()
} }
language!!.entries = entries.toTypedArray()
val lang = if (values.contains(Locale.getDefault().language)) Locale.getDefault().language else "en" val lang = if (values.contains(Locale.getDefault().language)) Locale.getDefault().language else "en"
editor.putString("app_language", lang) editor.putString("app_language", lang)
@ -258,14 +255,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
true true
} }
var cacheSize = requireContext().cacheDir.walkBottomUp().fold(0L) { acc, file -> acc + file.length() } var cacheSize = File(requireContext().cacheDir.absolutePath + "/downloads").walkBottomUp().fold(0L) { acc, file -> acc + file.length() }
clearCache!!.summary = "(${fileUtil!!.convertFileSize(cacheSize)}) ${resources.getString(R.string.clear_temporary_files_summary)}" clearCache!!.summary = "(${fileUtil!!.convertFileSize(cacheSize)}) ${resources.getString(R.string.clear_temporary_files_summary)}"
clearCache!!.onPreferenceClickListener = clearCache!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener { Preference.OnPreferenceClickListener {
if (activeDownloadCount == 0){ if (activeDownloadCount == 0){
requireContext().cacheDir.deleteRecursively() File(requireContext().cacheDir.absolutePath + "/downloads").deleteRecursively()
Toast.makeText(requireContext(), getString(R.string.cache_cleared), Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), getString(R.string.cache_cleared), Toast.LENGTH_SHORT).show()
cacheSize = requireContext().cacheDir.walkBottomUp().fold(0L) { acc, file -> acc + file.length() } cacheSize = File(requireContext().cacheDir.absolutePath + "/downloads").walkBottomUp().fold(0L) { acc, file -> acc + file.length() }
clearCache!!.summary = "(${fileUtil!!.convertFileSize(cacheSize)}) ${resources.getString(R.string.clear_temporary_files_summary)}" clearCache!!.summary = "(${fileUtil!!.convertFileSize(cacheSize)}) ${resources.getString(R.string.clear_temporary_files_summary)}"
}else{ }else{
Toast.makeText(requireContext(), getString(R.string.downloads_running_try_later), Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), getString(R.string.downloads_running_try_later), Toast.LENGTH_SHORT).show()

View file

@ -132,13 +132,13 @@ class FileUtil() {
} }
} }
originDir.delete() originDir.deleteRecursively()
return scanMedia(fileList, context) return scanMedia(fileList, context)
} }
fun getLogFile(context: Context, item: DownloadItem) : File { fun getLogFile(context: Context, item: DownloadItem) : File {
val titleRegex = Regex("[^A-Za-z\\d ]") val titleRegex = Regex("[^A-Za-z\\d ]")
return File(context.filesDir.absolutePath + """/logs/${item.id} - ${titleRegex.replace(item.title, "")}##${item.type}##${item.format.format_id}.log""") return File(context.filesDir.absolutePath + """/logs/${item.id} - ${titleRegex.replace(item.title, "").take(150)}##${item.type}##${item.format.format_id}.log""")
} }
fun getLogFileForTerminal(context: Context, command: String) : File { fun getLogFileForTerminal(context: Context, command: String) : File {
val titleRegex = Regex("[^A-Za-z\\d ]") val titleRegex = Regex("[^A-Za-z\\d ]")

View file

@ -630,23 +630,13 @@ class InfoUtil(context: Context) {
} }
fun getStreamingUrl(url: String) : String { fun getStreamingUrl(url: String) : String {
try { return try {
val request = YoutubeDLRequest(url) val request = YoutubeDLRequest(url)
request.addOption("-j") request.addOption("-f", "best")
request.addOption("--skip-download") val youtubeDlInfo = YoutubeDL.getInstance().getInfo(request)
request.addOption("-R", "1") youtubeDlInfo.url ?: ""
request.addOption("--socket-timeout", "5")
val youtubeDLResponse = YoutubeDL.getInstance().execute(request)
val results: Array<String?> = try {
val lineSeparator = System.getProperty("line.separator")
youtubeDLResponse.out.split(lineSeparator!!).toTypedArray()
} catch (e: Exception) {
arrayOf(youtubeDLResponse.out)
}
val jsonObject = JSONObject(results[0]!!)
return jsonObject.getString("urls")
} catch (e: Exception) { } catch (e: Exception) {
return "" ""
} }
} }

View file

@ -8,10 +8,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.work.ForegroundInfo import androidx.work.*
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.deniscerri.ytdlnis.MainActivity import com.deniscerri.ytdlnis.MainActivity
import com.deniscerri.ytdlnis.R import com.deniscerri.ytdlnis.R
import com.deniscerri.ytdlnis.database.DBManager import com.deniscerri.ytdlnis.database.DBManager
@ -69,9 +66,9 @@ class DownloadWorker(
val type = downloadItem.type val type = downloadItem.type
val downloadLocation = downloadItem.downloadPath val downloadLocation = downloadItem.downloadPath
val tempFileDir = File(context.cacheDir.absolutePath + "/" + downloadItem.id) val tempFileDir = File(context.cacheDir.absolutePath + "/downloads/" + downloadItem.id)
tempFileDir.delete() tempFileDir.delete()
tempFileDir.mkdir() tempFileDir.mkdirs()
val sharedPreferences = context.getSharedPreferences("root_preferences", val sharedPreferences = context.getSharedPreferences("root_preferences",
Service.MODE_PRIVATE Service.MODE_PRIVATE
@ -111,10 +108,10 @@ class DownloadWorker(
} }
if(downloadItem.title.isNotBlank()){ if(downloadItem.title.isNotBlank()){
request.addCommands(listOf("--replace-in-metadata","title",".*.",downloadItem.title)) request.addCommands(listOf("--replace-in-metadata","title",".*.",downloadItem.title.take(200)))
} }
if (downloadItem.author.isNotBlank()){ if (downloadItem.author.isNotBlank()){
request.addCommands(listOf("--replace-in-metadata","uploader",".*.",downloadItem.author)) request.addCommands(listOf("--replace-in-metadata","uploader",".*.",downloadItem.author.take(25)))
} }
if (downloadItem.customFileNameTemplate.isBlank()) downloadItem.customFileNameTemplate = "%(uploader)s - %(title)s" if (downloadItem.customFileNameTemplate.isBlank()) downloadItem.customFileNameTemplate = "%(uploader)s - %(title)s"
@ -127,8 +124,6 @@ class DownloadWorker(
request.addOption("--restrict-filenames") request.addOption("--restrict-filenames")
} }
request.addOption("--trim-filenames", 100)
when(type){ when(type){
DownloadViewModel.Type.audio -> { DownloadViewModel.Type.audio -> {
request.addOption("-x") request.addOption("-x")
@ -150,7 +145,7 @@ class DownloadWorker(
request.addOption("--embed-thumbnail") request.addOption("--embed-thumbnail")
request.addOption("--convert-thumbnails", "png") request.addOption("--convert-thumbnails", "png")
try { try {
val config = File(context.cacheDir, "config" + downloadItem.title + "##" + downloadItem.format.format_id + ".txt") val config = File(context.cacheDir.absolutePath + "/downloads/config" + downloadItem.title + "##" + downloadItem.format.format_id + ".txt")
val configData = val configData =
"--ppa \"ffmpeg: -c:v png -vf crop=\\\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\\\"\"" "--ppa \"ffmpeg: -c:v png -vf crop=\\\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\\\"\""
config.writeText(configData) config.writeText(configData)
@ -177,6 +172,7 @@ class DownloadWorker(
DownloadViewModel.Type.video -> { DownloadViewModel.Type.video -> {
if (downloadItem.videoPreferences.addChapters) { if (downloadItem.videoPreferences.addChapters) {
request.addOption("--sponsorblock-mark", "all") request.addOption("--sponsorblock-mark", "all")
request.addOption("--embed-chapters")
} }
if (downloadItem.videoPreferences.embedSubs) { if (downloadItem.videoPreferences.embedSubs) {
request.addOption("--embed-subs", "") request.addOption("--embed-subs", "")
@ -216,7 +212,7 @@ class DownloadWorker(
DownloadViewModel.Type.command -> { DownloadViewModel.Type.command -> {
request.addOption( request.addOption(
"--config-locations", "--config-locations",
File(context.cacheDir, "config${System.currentTimeMillis()}.txt").apply { File(context.cacheDir.absolutePath + "/downloads/config${System.currentTimeMillis()}.txt").apply {
writeText(downloadItem.format.format_note) writeText(downloadItem.format.format_note)
}.absolutePath }.absolutePath
) )
@ -255,12 +251,13 @@ class DownloadWorker(
} }
}.onSuccess { }.onSuccess {
//move file from internal to set download directory //move file from internal to set download directory
setProgressAsync(workDataOf("progress" to 100, "output" to "Moving file to $downloadLocation", "id" to downloadItem.id, "log" to logDownloads))
var finalPath : String? var finalPath : String?
try { try {
finalPath = moveFile(tempFileDir.absoluteFile, downloadLocation){ progress -> finalPath = moveFile(tempFileDir.absoluteFile, downloadLocation){ progress ->
setProgressAsync(workDataOf("progress" to progress)) setProgressAsync(workDataOf("progress" to progress, "output" to "Moving file to $downloadLocation", "id" to downloadItem.id, "log" to logDownloads))
} }
setProgressAsync(workDataOf("progress" to 1000, "output" to "Moved file to $finalPath", "id" to downloadItem.id, "log" to logDownloads)) setProgressAsync(workDataOf("progress" to 100, "output" to "Moved file to $finalPath", "id" to downloadItem.id, "log" to logDownloads))
}catch (e: Exception){ }catch (e: Exception){
finalPath = context.getString(R.string.unfound_file) finalPath = context.getString(R.string.unfound_file)
e.printStackTrace() e.printStackTrace()
@ -288,7 +285,9 @@ class DownloadWorker(
runBlocking { runBlocking {
dao.update(downloadItem) dao.update(downloadItem)
} }
return Result.failure() return Result.failure(
Data.Builder().putString("output", "Download has been cancelled!").build()
)
}else{ }else{
if (logDownloads && logFile.exists()){ if (logDownloads && logFile.exists()){
logFile.appendText("${it.message}\n") logFile.appendText("${it.message}\n")
@ -310,7 +309,9 @@ class DownloadWorker(
runBlocking { runBlocking {
dao.update(downloadItem) dao.update(downloadItem)
} }
return Result.failure() return Result.failure(
Data.Builder().putString("output", it.toString()).build()
)
} }
} }

View file

@ -8,10 +8,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.work.ForegroundInfo import androidx.work.*
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.deniscerri.ytdlnis.MainActivity import com.deniscerri.ytdlnis.MainActivity
import com.deniscerri.ytdlnis.R import com.deniscerri.ytdlnis.R
import com.deniscerri.ytdlnis.util.FileUtil import com.deniscerri.ytdlnis.util.FileUtil
@ -48,13 +45,13 @@ class TerminalDownloadWorker(
) )
val downloadLocation = sharedPreferences.getString("command_path", context.getString(R.string.command_path)) val downloadLocation = sharedPreferences.getString("command_path", context.getString(R.string.command_path))
val tempFileDir = File(context.cacheDir.absolutePath + "/" + itemId) val tempFileDir = File(context.cacheDir.absolutePath + "/downloads/" + itemId)
tempFileDir.delete() tempFileDir.delete()
tempFileDir.mkdir() tempFileDir.mkdirs()
request.addOption( request.addOption(
"--config-locations", "--config-locations",
File(context.cacheDir, "config${System.currentTimeMillis()}.txt").apply { File(context.cacheDir.absolutePath + "/downloads/config${System.currentTimeMillis()}.txt").apply {
writeText(command) writeText(command)
}.absolutePath }.absolutePath
) )
@ -93,7 +90,6 @@ class TerminalDownloadWorker(
finalPath = moveFile(tempFileDir.absoluteFile, downloadLocation!!){ progress -> finalPath = moveFile(tempFileDir.absoluteFile, downloadLocation!!){ progress ->
setProgressAsync(workDataOf("progress" to progress)) setProgressAsync(workDataOf("progress" to progress))
} }
setProgressAsync(workDataOf("progress" to 100, "output" to "Moved file to $finalPath", "id" to itemId, "log" to logDownloads))
}catch (e: Exception){ }catch (e: Exception){
finalPath = context.getString(R.string.unfound_file) finalPath = context.getString(R.string.unfound_file)
e.printStackTrace() e.printStackTrace()
@ -101,20 +97,7 @@ class TerminalDownloadWorker(
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}, 1000) }, 1000)
} }
//put download in history
// val incognito = sharedPreferences.getBoolean("incognito", false)
// if (!incognito) {
// val unixtime = System.currentTimeMillis() / 1000
// val file = File(finalPath!!)
// downloadItem.format.filesize = if (file.exists()) file.length() else 0L
// val historyItem = HistoryItem(0, downloadItem.url, downloadItem.title, downloadItem.author, downloadItem.duration, downloadItem.thumb, downloadItem.type, unixtime, finalPath, downloadItem.website, downloadItem.format)
// runBlocking {
// historyDao.insert(historyItem)
// }
// }
// runBlocking {
// dao.delete(downloadItem.id)
// }
}.onFailure { }.onFailure {
if (it is YoutubeDL.CanceledException) { if (it is YoutubeDL.CanceledException) {
return Result.failure() return Result.failure()
@ -123,8 +106,6 @@ class TerminalDownloadWorker(
if (logDownloads && logFile.exists()){ if (logDownloads && logFile.exists()){
logFile.appendText("${it.message}\n") logFile.appendText("${it.message}\n")
} }
setProgressAsync(workDataOf("progress" to -1, "output" to it.message, "id" to itemId, "log" to logDownloads))
tempFileDir.delete() tempFileDir.delete()
Log.e(TAG, context.getString(R.string.failed_download), it) Log.e(TAG, context.getString(R.string.failed_download), it)
@ -133,7 +114,14 @@ class TerminalDownloadWorker(
context.getString(R.string.failed_download), 0, 0, command.take(20), context.getString(R.string.failed_download), 0, 0, command.take(20),
NotificationUtil.DOWNLOAD_SERVICE_CHANNEL_ID NotificationUtil.DOWNLOAD_SERVICE_CHANNEL_ID
) )
return Result.failure()
if (logDownloads && logFile.exists()){
logFile.appendText("${it.message}\n")
}
return Result.failure(
Data.Builder().putString("output", it.message.toString()).build()
)
} }
return Result.success() return Result.success()

View file

@ -105,13 +105,13 @@
<HorizontalScrollView <HorizontalScrollView
android:id="@+id/linearlayout2_horizontalscrollview" android:id="@+id/linearlayout2_horizontalscrollview"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:scrollbars="none"
android:paddingTop="10dp" android:paddingTop="10dp"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/barrier" app:layout_constraintTop_toBottomOf="@+id/barrier">
app:layout_constraintVertical_bias="1.0">
<com.google.android.material.chip.ChipGroup <com.google.android.material.chip.ChipGroup
android:id="@+id/linearLayout2" android:id="@+id/linearLayout2"

View file

@ -27,7 +27,6 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:descendantFocusability="blocksDescendants"
android:orientation="vertical"> android:orientation="vertical">
<ScrollView <ScrollView
@ -38,15 +37,23 @@
android:paddingBottom="150dp" android:paddingBottom="150dp"
> >
<TextView <LinearLayout
android:id="@+id/content"
android:padding="10dp"
android:fontFamily="monospace"
android:textSize="15sp"
android:gravity="bottom"
android:layout_width="match_parent" android:layout_width="match_parent"
android:textIsSelectable="true" android:layout_height="wrap_content"
android:layout_height="wrap_content" /> android:focusableInTouchMode="true"
android:orientation="vertical">
<TextView
android:id="@+id/content"
android:padding="10dp"
android:fontFamily="monospace"
android:textSize="15sp"
android:gravity="bottom"
android:layout_width="match_parent"
android:textIsSelectable="true"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView> </ScrollView>