This commit is contained in:
lunaticbum 2026-03-19 18:15:08 +09:00
parent d4ec1d5652
commit 406e7820bc
11 changed files with 560 additions and 187 deletions

View File

@ -55,6 +55,7 @@
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />

View File

@ -51,6 +51,10 @@ import bums.lunatic.launcher.common.CommonActivity
import bums.lunatic.launcher.databinding.LauncherActivityBinding
import bums.lunatic.launcher.feeds.WidgetHost
import bums.lunatic.launcher.helpers.ForeGroundService
import bums.lunatic.launcher.helpers.ForeGroundService.Companion.ACTION_VIDEO_DOWNLOAD
import bums.lunatic.launcher.helpers.ForeGroundService.Companion.EXTRA_MSGKEY
import bums.lunatic.launcher.helpers.ForeGroundService.Companion.EXTRA_PROGRESS
import bums.lunatic.launcher.helpers.ForeGroundService.Companion.EXTRA_TARGET_URL
import bums.lunatic.launcher.helpers.HeadsetActionButtonReceiver
import bums.lunatic.launcher.home.GeckoWeb
import bums.lunatic.launcher.home.NeoRssActivity
@ -227,45 +231,73 @@ open class LauncherActivity : CommonActivity() {
// 💡 URI로부터 파일을 읽어서 프라이빗 폴더로 복사하는 함수
private fun saveToPrivateVault(uri: Uri) {
var fileName = "shared_${System.currentTimeMillis()}"
CoroutineScope(Dispatchers.IO).launch {
try {
// 1. 원본 파일명 알아내기
var fileName = "shared_${System.currentTimeMillis()}"
// 1. 원본 파일명 및 총 파일 크기(용량) 알아내기
var totalFileSize = 0L // 💡 파일 크기를 담을 변수 추가
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
fileName = cursor.getString(nameIndex)
}
// 💡 SIZE 컬럼을 통해 파일의 전체 바이트 크기를 가져옵니다.
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1) {
totalFileSize = cursor.getLong(sizeIndex)
}
}
}
val vaultDir = File(getExternalFilesDir(null), "completed_torrents")
if (!vaultDir.exists()) vaultDir.mkdirs()
// 2. 이름 중복 덮어쓰기 방지를 위해 타임스탬프 추가
val destFile = File(vaultDir, "${System.currentTimeMillis()}_$fileName")
// 3. 스트림 복사 (외부 파일 -> 내 프라이빗 폴더)
notiState("${destFile.name} 복사 시작",0)
// 3. 수동 스트림 복사 및 진행률 계산
contentResolver.openInputStream(uri)?.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
val buffer = ByteArray(8 * 1024) // 8KB씩 쪼개서 복사
var bytesRead: Int
var copiedSize = 0L
var lastReportedProgress = -1
// 💡 파일 끝에 도달할 때까지 8KB씩 계속 읽고 씁니다.
while (input.read(buffer).also { bytesRead = it } >= 0) {
output.write(buffer, 0, bytesRead)
copiedSize += bytesRead
// 진행률 계산 로직 (총 크기를 아는 경우에만)
if (totalFileSize > 0) {
val progress = ((copiedSize.toDouble() / totalFileSize.toDouble()) * 100).toInt()
// 1% 단위로 변경될 때만 알림을 갱신합니다 (시스템 과부하 방지)
if (progress != lastReportedProgress) {
lastReportedProgress = progress
notiState("${destFile.name} ${progress}% 복사 중",progress)
}
}
}
}
}
// 4. 완료 후 UI 스레드에서 토스트 띄우기
withContext(Dispatchers.Main) {
showToast("보관함에 저장되었습니다: $fileName")
}
notiState("${destFile.name} ${100}% 복사 완료",100)
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
showToast("파일을 보관함으로 복사하지 못했습니다.")
}
notiState("${fileName} 복사 실패 : ${e.message}",0)
}
}
}
fun notiState(msg : String, progress : Int) {
val intent = Intent(this@LauncherActivity, ForeGroundService::class.java).apply {
action = ForeGroundService.ACTION_COPY_COMPLETE
putExtra(EXTRA_MSGKEY, msg)
putExtra(EXTRA_PROGRESS, progress)
}
startService(intent)
}
fun onSwipeLeft() {
showAppDrawer()
}

View File

@ -49,7 +49,10 @@ import java.util.concurrent.TimeUnit
import android.bluetooth.BluetoothClass
import android.media.AudioManager
import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.KeyEvent
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.delay
class AggregatedSystemWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
@ -85,10 +88,13 @@ class ForeGroundService : Service() {
companion object {
val ACTION_SENDMSG = "ACTION_SEND_TO_LOVE"
val EXTRA_MSGKEY = "SEND_MSG"
val EXTRA_PROGRESS = "EXTRA_PROGRESS"
val ACTION_VIDEO_DOWNLOAD = "ACTION_YTURL_DOWNLOAD"
val EXTRA_TARGET_URL = "ACTION_SEND_TO_LOVE"
val ACTION_SIT_DOWN = "ACTION_SIT_DOWN"
val ACTION_COPY_COMPLETE = "ACTION_COPY_COMPLETE"
val targetUrls = arrayListOf<String>()
}
@ -108,7 +114,7 @@ class ForeGroundService : Service() {
val filter = IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED)
registerReceiver(bluetoothreceiver, filter)
refreshFeeds()
startForeGround()
startForeGround(vibrator = true)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -137,7 +143,10 @@ class ForeGroundService : Service() {
sitDownRequest
)
// 사용자 확인을 위해 알림 텍스트 변경 가능
startForeGround(0, 0, "45분 뒤에 진동 알림이 예약되었습니다.")
startForeGround(max = 0, progress = 0, str = "45분 뒤에 진동 알림이 예약되었습니다.")
}
ACTION_COPY_COMPLETE -> {
startForeGround(max = 100, progress = intent?.getIntExtra(EXTRA_PROGRESS,0) ?: 0, str = intent?.getStringExtra(EXTRA_MSGKEY))
}
else -> {
@ -161,7 +170,7 @@ class ForeGroundService : Service() {
set(value) {
field = value
if (value == null) {
startForeGround(0,0)
startForeGround(max= 0, progress = 0, vibrator = true)
}
}
@ -181,7 +190,7 @@ class ForeGroundService : Service() {
currentProcessId = UUID.randomUUID().toString()
YoutubeDL.getInstance()
.execute(command, currentProcessId) { progress, est, str ->
startForeGround(100, progress.toInt(),str)
startForeGround(100, progress.toInt(),str, true)
if (progress >= 100) {
targetUrls.remove(url)
currentProcessId = null
@ -201,17 +210,40 @@ class ForeGroundService : Service() {
}
}
fun startForeGround(max : Int = 0 , progress : Int = 0, str : String? = "실행중입니다.") {
Blog.LOGE(str?:"")
fun startForeGround(max : Int = 0, progress : Int = 0, str : String? = "실행중입니다.", vibrator : Boolean? = false) {
val currentChannelId = if (vibrator == true) "${CHANNEL_ID}_vibrate" else "${CHANNEL_ID}_silent"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
currentChannelId,
"BUM'S 서비스",
NotificationManager.IMPORTANCE_HIGH
)
val manager = getSystemService(NotificationManager::class.java)
).apply {
if (vibrator == true) {
enableVibration(true)
vibrationPattern = longArrayOf(0, 500) // 0초 대기 후 0.5초 진동
} else {
enableVibration(false)
setSound(null, null) // 소리도 확실하게 제거
}
}
manager.createNotificationChannel(channel)
}
// 💡 2. 파라미터가 true일 때, 알림 채널과 별개로 진동 모터를 직접 한 번 강제로 울려줍니다.
// (화면이 켜져 있거나 시스템 설정에 의해 알림 진동이 무시되는 경우를 대비한 확실한 장치)
if (vibrator == true) {
val vibratorService = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibratorService.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
vibratorService.vibrate(500)
}
}
val intent = Intent(this, LauncherActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
@ -223,17 +255,24 @@ class ForeGroundService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
startForeground(NOTIF_ID, NotificationCompat.Builder(this, CHANNEL_ID)
// 💡 3. Builder에 currentChannelId 적용 및 하위 버전(Oreo 미만) 진동 호환성 추가
val builder = NotificationCompat.Builder(this, currentChannelId)
.setContentTitle("BUM'S 서비스")
.setContentText(str)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setSmallIcon(R.drawable.ic_b)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_btn_speak_now,"퇴근", makeSendMsgAction(0,"돼지 퇴근했다요~!"))
.addAction(android.R.drawable.ic_btn_speak_now, "퇴근", makeSendMsgAction(0, "돼지 퇴근했다요~!"))
.addAction(android.R.drawable.ic_menu_directions, "앉음", makeSitDownAction())
.setOngoing(true) // 사용자가 알림을 스와이프로 지울 수 없게 만듦
.setOngoing(true)
.setProgress(max, progress, false)
.build())
// 안드로이드 8.0 미만 기기를 위한 진동 처리
if (vibrator == true) {
builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE)
}
startForeground(NOTIF_ID, builder.build())
}
fun makeSitDownAction(): PendingIntent {

View File

@ -15,51 +15,45 @@ import android.widget.ImageView
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import bums.lunatic.launcher.R
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.CommonUtils.getFileTypeBySignature
import bums.lunatic.launcher.utils.NaturalOrderComparator
import com.bumptech.glide.Glide
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
// 필터 및 정렬 관리를 위한 Enum
//"전체", "이미지", "영상", "문서", "기타"
enum class FileFilterType(val label : String) { ALL("전체"), IMAGE("이미지"), VIDEO("영상"), DOCUMENT("문서"), OTHER("기타") }
enum class FileSortType(val label : String) { DOWNLOAD_DATE("다운로드"), LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), SIZE("용량") }
enum class FileSortType(val label : String) { NAME("파일명") , DOWNLOAD_DATE("다운로드"), LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), SIZE("용량") }
enum class FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
//val sortOptions = arrayOf("다운로드일", "최근 사용", "자주 사용", "용량")
class CompletedFilesFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: CompletedFilesAdapter
// 원본 파일 목록
private lateinit var rootDir: File
private lateinit var currentDir: File
private var allFiles = listOf<File>()
// 현재 선택된 상태값
private var currentFilter = FileFilterType.ALL
private var currentSort = FileSortType.DOWNLOAD_DATE
private var currentViewMode = FileViewMode.LIST_TEXT // 기본값: 썸네일 리스트
private var isDescending = true // 기본: 내림차순 (최신순/큰용량순)
private var currentSort = FileSortType.NAME
private var currentViewMode = FileViewMode.LIST_TEXT
private var isDescending = true
// 파일 확장자 분류 기준
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp")
private lateinit var backPressedCallback: OnBackPressedCallback
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
@ -70,6 +64,17 @@ class CompletedFilesFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
rootDir = File(requireContext().getExternalFilesDir(null), "completed_torrents")
if (!rootDir.exists()) rootDir.mkdirs()
currentDir = rootDir
backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
navigateUp()
}
}
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback)
setupRecyclerView(view)
setupControls(view)
loadFiles()
@ -77,20 +82,30 @@ class CompletedFilesFragment : Fragment() {
private fun setupRecyclerView(view: View) {
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
updateRecyclerViewLayoutManager()
// 💡 어댑터 생성 시 getAccessCount 함수를 넘겨주어 어댑터 내부에서 조회수를 그릴 수 있게 합니다.
adapter = CompletedFilesAdapter(
onItemClick = { file ->
openPrivateFile(requireContext(), file)
if (file.name == "..") {
navigateUp()
} else if (file.isDirectory) {
currentDir = file
loadFiles()
} else {
openPrivateFile(requireContext(), file)
}
},
onItemLongClick = { file ->
val type = if (file.isDirectory) "폴더" else "파일"
android.app.AlertDialog.Builder(context)
.setTitle("파일 삭제")
.setMessage("해당 파일을 삭제하시겠습니까?")
.setTitle("$type 삭제")
.setMessage("해당 ${type}를 삭제하시겠습니까?\n(폴더일 경우 내부 파일도 모두 삭제됩니다)")
.setPositiveButton("확인") { _, _ ->
CoroutineScope(Dispatchers.IO).launch {
if (file.delete()) {
CoroutineScope(Dispatchers.Main).launch {
val success = if (file.isDirectory) file.deleteRecursively() else file.delete()
if (success) {
withContext(Dispatchers.Main) {
Toast.makeText(requireContext(), "삭제되었습니다.", Toast.LENGTH_SHORT).show()
loadFiles()
}
@ -99,18 +114,26 @@ class CompletedFilesFragment : Fragment() {
}
.setNegativeButton("취소", null)
.show()
}
},
getAccessCount = { fileName -> getAccessCount(fileName) }
)
adapter.setViewMode(currentViewMode)
recyclerView.adapter = adapter
}
private fun navigateUp() {
if (currentDir.absolutePath != rootDir.absolutePath) {
currentDir = currentDir.parentFile ?: rootDir
loadFiles()
}
}
private fun getViewModeSymbolName(mode: FileViewMode): String {
return when (mode) {
FileViewMode.LIST_TEXT -> "view_headline" // 텍스트 리스트
FileViewMode.LIST_THUMB -> "view_list" // 썸네일 리스트
FileViewMode.GRID_LARGE -> "grid_view" // 큰 그리드
FileViewMode.GRID_SMALL -> "apps" // 작은 그리드 (view_comfy 도 가능)
FileViewMode.LIST_TEXT -> "view_headline"
FileViewMode.LIST_THUMB -> "view_list"
FileViewMode.GRID_LARGE -> "grid_view"
FileViewMode.GRID_SMALL -> "apps"
}
}
@ -118,12 +141,15 @@ class CompletedFilesFragment : Fragment() {
val spinnerFilter = view.findViewById<Spinner>(R.id.spinnerFilter)
val spinnerSort = view.findViewById<Spinner>(R.id.spinnerSort)
val tvSortOrder = view.findViewById<TextView>(R.id.tvSortOrder)
val btnViewMode = view.findViewById<TextView>(R.id.btnViewMode)
// 초기 뷰 모드에 맞는 이모지 세팅
// 💡 XML에 추가된 자동 정리 버튼 연동 (ID를 btnOrganize로 만들었다고 가정)
val btnOrganize = view.findViewById<TextView>(R.id.btnOrganize)
btnOrganize?.setOnClickListener {
organizeRootFiles()
}
btnViewMode.text = getViewModeSymbolName(currentViewMode)
adapter.setViewMode(currentViewMode)
btnViewMode.setOnClickListener {
currentViewMode = when (currentViewMode) {
FileViewMode.LIST_TEXT -> FileViewMode.LIST_THUMB
@ -133,13 +159,14 @@ class CompletedFilesFragment : Fragment() {
}
btnViewMode.text = getViewModeSymbolName(currentViewMode)
updateRecyclerViewLayoutManager()
// 모드 변경을 어댑터에 알림
adapter.setViewMode(currentViewMode)
}
// 1. 필터 스피너 설정
val filterOptions = FileFilterType.values().map { it.label }
spinnerFilter.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions)
spinnerFilter.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions).apply {
setDropDownViewResource(R.layout.spinner_item_dark)
}
spinnerFilter.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
currentFilter = FileFilterType.values()[position]
@ -148,9 +175,11 @@ class CompletedFilesFragment : Fragment() {
override fun onNothingSelected(p0: AdapterView<*>?) {}
}
// 2. 정렬 스피너 설정
val sortOptions = FileSortType.values().map { it.label }
spinnerSort.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, sortOptions)
spinnerSort.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, sortOptions).apply {
setDropDownViewResource(R.layout.spinner_item_dark)
}
spinnerSort.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
currentSort = FileSortType.values()[position]
@ -159,36 +188,78 @@ class CompletedFilesFragment : Fragment() {
override fun onNothingSelected(p0: AdapterView<*>?) {}
}
// 3. 정순/역순 토글 버튼 설정
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
tvSortOrder.setOnClickListener {
isDescending = !isDescending
// 내림차순이면 아래 화살표, 오름차순이면 위 화살표
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
applyFilterAndSort()
}
}
// 파일 목록 불러오기
// 💡 루트 경로에 있는 파일들을 각각의 폴더로 이동시키는 자동 정리 함수
private fun organizeRootFiles() {
val filesInRoot = rootDir.listFiles()?.filter { it.isFile } ?: emptyList()
if (filesInRoot.isEmpty()) {
Toast.makeText(requireContext(), "루트 폴더에 정리할 파일이 없습니다.", Toast.LENGTH_SHORT).show()
return
}
var movedCount = 0
CoroutineScope(Dispatchers.IO).launch {
filesInRoot.forEach { file ->
val ext = file.extension.lowercase()
val folderName = when {
extImages.contains(ext) -> "이미지"
extVideos.contains(ext) -> "비디오"
extDocs.contains(ext) -> "문서"
else -> "기타"
}
val targetDir = File(rootDir, folderName)
if (!targetDir.exists()) targetDir.mkdirs()
val targetFile = File(targetDir, file.name)
// 동일한 이름의 파일이 이미 있다면 덮어쓰거나 이동 실패할 수 있으므로 주의 (필요시 중복 네이밍 처리)
if (file.renameTo(targetFile)) {
movedCount++
}
}
withContext(Dispatchers.Main) {
if (movedCount > 0) {
Toast.makeText(requireContext(), "${movedCount}개의 파일을 자동 정리했습니다.", Toast.LENGTH_SHORT).show()
// 만약 사용자가 지금 루트 폴더를 보고 있다면 리스트를 즉시 갱신
if (currentDir.absolutePath == rootDir.absolutePath) {
loadFiles()
}
} else {
Toast.makeText(requireContext(), "파일 정리에 실패했습니다.", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun loadFiles() {
val completedDir = File(requireContext().getExternalFilesDir(null), "completed_torrents")
if (completedDir.exists()) {
allFiles = completedDir.walkTopDown().filter { it.isFile }.toList()
if (currentDir.exists()) {
allFiles = currentDir.listFiles()?.toList() ?: emptyList()
} else {
allFiles = emptyList()
}
backPressedCallback.isEnabled = currentDir.absolutePath != rootDir.absolutePath
applyFilterAndSort()
}
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
loadFiles()
if (!hidden) loadFiles()
}
// 💡 핵심 로직: 필터 및 정렬 적용
private fun applyFilterAndSort() {
// 1. 필터링
var processedList = allFiles.filter { file ->
val folders = allFiles.filter { it.isDirectory }
var files = allFiles.filter { it.isFile }
files = files.filter { file ->
val ext = file.extension.lowercase()
when (currentFilter) {
FileFilterType.ALL -> true
@ -198,86 +269,84 @@ class CompletedFilesFragment : Fragment() {
FileFilterType.OTHER -> !extImages.contains(ext) && !extVideos.contains(ext) && !extDocs.contains(ext)
}
}
// 2. 정렬
processedList = when (currentSort) {
val naturalComparator = NaturalOrderComparator()
val sortedFolders = folders.sortedBy { it.name.lowercase() }
val sortedFiles = when (currentSort) {
FileSortType.DOWNLOAD_DATE -> {
if (isDescending) processedList.sortedByDescending { it.lastModified() }
else processedList.sortedBy { it.lastModified() }
if (isDescending) files.sortedByDescending { it.lastModified() }
else files.sortedBy { it.lastModified() }
}
FileSortType.LAST_USED -> {
if (isDescending) processedList.sortedByDescending { getLastAccessedTime(it.name) }
else processedList.sortedBy { getLastAccessedTime(it.name) }
if (isDescending) files.sortedByDescending { getLastAccessedTime(it.name) }
else files.sortedBy { getLastAccessedTime(it.name) }
}
FileSortType.SIZE -> {
if (isDescending) processedList.sortedByDescending { it.length() }
else processedList.sortedBy { it.length() }
if (isDescending) files.sortedByDescending { it.length() }
else files.sortedBy { it.length() }
}
FileSortType.FREQUENTLY_USED -> {
if (isDescending) processedList.sortedByDescending { getAccessCount(it.name) }
else processedList.sortedBy { getAccessCount(it.name) }
if (isDescending) files.sortedByDescending { getAccessCount(it.name) }
else files.sortedBy { getAccessCount(it.name) }
}
FileSortType.NAME -> {
if (isDescending) files.sortedWith(naturalComparator.reversed())
else files.sortedWith(naturalComparator)
}
}
adapter.submitList(processedList)
val finalItems = mutableListOf<File>()
if (currentDir.absolutePath != rootDir.absolutePath) {
finalItems.add(File(currentDir, ".."))
}
finalItems.addAll(sortedFolders)
finalItems.addAll(sortedFiles)
adapter.submitList(finalItems)
}
private fun trackFileAccess(fileName: String) {
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
// 기존 횟수를 불러와서 1을 더함 (처음 열면 0 + 1)
val currentCount = prefs.getInt("${fileName}_count", 0)
prefs.edit()
.putLong(fileName, System.currentTimeMillis()) // 기존 시간 기록 유지
.putInt("${fileName}_count", currentCount + 1) // 횟수 기록 추가
.putLong(fileName, System.currentTimeMillis())
.putInt("${fileName}_count", currentCount + 1)
.apply()
}
// '최근 사용 시간' 불러오기 (기존 함수 유지)
private fun getLastAccessedTime(fileName: String): Long {
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
return prefs.getLong(fileName, 0L)
}
// 💡 '접근 빈도(횟수)' 불러오기 (신규)
private fun getAccessCount(fileName: String): Int {
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
return prefs.getInt("${fileName}_count", 0)
}
// 💡 확장자에 맞춰 동적 MIME 타입 생성
private fun getMimeType(file: File): String {
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(file).toString()) ?: file.extension
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) ?: "*/*"
}
// 외부 플레이어/뷰어로 파일 열기
private fun openPrivateFile(context: Context, file: File) {
try {
// 파일을 열었으므로 최근 사용 시간 갱신
trackFileAccess(file.name)
val uri: Uri = FileProvider.getUriForFile(
context,
"bums.lunatic.launcher.fileprovider",
file
)
// 💡 파일을 열고나서 즉시 접근 횟수 및 뷰 반영을 위해 다시 정렬/로드합니다.
loadFiles()
val uri: Uri = FileProvider.getUriForFile(context, "bums.lunatic.launcher.fileprovider", file)
val mimeType = getMimeType(file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
// 열고 나서 '최근 사용' 순서가 갱신되어야 하므로 리스트 다시 정렬
if (currentSort == FileSortType.LAST_USED) {
applyFilterAndSort()
}
} catch (e: IllegalArgumentException) {
e.printStackTrace()
Toast.makeText(context, "파일을 공유할 수 없습니다.", Toast.LENGTH_SHORT).show()
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, "이 파일을 열 수 있는 앱이 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
@ -289,18 +358,18 @@ class CompletedFilesFragment : Fragment() {
FileViewMode.LIST_TEXT, FileViewMode.LIST_THUMB ->
LinearLayoutManager(requireContext())
FileViewMode.GRID_LARGE ->
GridLayoutManager(requireContext(), 2) // 3열 그리드
GridLayoutManager(requireContext(), 2)
FileViewMode.GRID_SMALL ->
GridLayoutManager(requireContext(), 4) // 5열 그리드
GridLayoutManager(requireContext(), 4)
}
}
}
// --- RecyclerView 어댑터 ---
class CompletedFilesAdapter(
private val onItemClick: (File) -> Unit,
private val onItemLongClick: (File) -> Unit
private val onItemLongClick: (File) -> Unit,
private val getAccessCount: (String) -> Int // 💡 접근 빈도를 가져오기 위한 람다 함수 추가
) : RecyclerView.Adapter<CompletedFilesAdapter.FileViewHolder>() {
private var fileList: List<File> = emptyList()
@ -313,10 +382,9 @@ class CompletedFilesAdapter(
fun setViewMode(mode: FileViewMode) {
viewMode = mode
notifyDataSetChanged() // 모드가 바뀌면 전체 리스트를 다시 그립니다.
notifyDataSetChanged()
}
// 💡 모드에 따라 ViewType을 다르게 반환
override fun getItemViewType(position: Int): Int {
return viewMode.ordinal
}
@ -338,29 +406,49 @@ class CompletedFilesAdapter(
override fun getItemCount(): Int = fileList.size
inner class FileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
// 레이아웃에 따라 특정 뷰가 null일 수 있으므로 nullable(?)로 선언
private val tvFileName: TextView? = itemView.findViewById(R.id.tvFileName)
private val tvFileSize: TextView? = itemView.findViewById(R.id.tvFileSize)
private val ivThumb: ImageView? = itemView.findViewById(R.id.ivThumb)
fun bind(file: File, mode: FileViewMode) {
tvFileName?.text = file.name
if (file.name == "..") {
tvFileName?.text = "📁 .. (상위 폴더로)"
tvFileSize?.text = "이전 경로"
val sizeMb = file.length() / (1024.0 * 1024.0)
tvFileSize?.text = String.format("%.2f MB • %s", sizeMb, file.extension.uppercase())
if (ivThumb != null) {
Glide.with(itemView.context).clear(ivThumb)
ivThumb.setImageResource(android.R.drawable.ic_menu_revert)
}
} else if (file.isDirectory) {
tvFileName?.text = "📁 ${file.name}"
val count = file.listFiles()?.size ?: 0
tvFileSize?.text = "항목 $count"
// 💡 썸네일 뷰가 존재하는 모드일 경우 Glide로 이미지 로딩
if (ivThumb != null) {
// 이미지나 영상 파일인 경우 썸네일 표시, 아니면 기본 아이콘 등 처리 가능
Glide.with(itemView.context)
.load(file)
.placeholder(android.R.drawable.ic_menu_report_image) // 로딩 중 기본 이미지
.into(ivThumb)
if (ivThumb != null) {
Glide.with(itemView.context).clear(ivThumb)
ivThumb.setImageResource(android.R.drawable.ic_menu_gallery)
}
} else {
tvFileName?.text = file.name
val sizeMb = file.length() / (1024.0 * 1024.0)
// 💡 접근 빈도를 가져와 텍스트에 포함시킵니다.
val accessCount = getAccessCount(file.name)
val countText = if (accessCount > 0) "조회: ${accessCount}" else "새 파일"
tvFileSize?.text = String.format("%.2f MB • %s • %s", sizeMb, file.extension.uppercase(), countText)
if (ivThumb != null) {
Glide.with(itemView.context)
.load(file)
.placeholder(android.R.drawable.ic_menu_report_image)
.into(ivThumb)
}
}
itemView.setOnClickListener { onItemClick(file) }
itemView.setOnLongClickListener {
onItemLongClick(file)
if (file.name != "..") onItemLongClick(file)
true
}
}

View File

@ -48,6 +48,11 @@ import io.realm.kotlin.Realm
import io.realm.kotlin.UpdatePolicy
import io.realm.kotlin.ext.copyFromRealm
import io.realm.kotlin.ext.query
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONException
import org.json.JSONObject
import org.mozilla.gecko.util.ThreadUtils
@ -60,6 +65,7 @@ import org.mozilla.geckoview.WebExtension.MessageDelegate
import org.mozilla.geckoview.WebExtension.PortDelegate
import org.mozilla.geckoview.WebExtensionController.AddonManagerDelegate
import org.mozilla.geckoview.WebRequestError
import java.io.File
import java.lang.System.currentTimeMillis
import java.net.URL
import java.text.SimpleDateFormat
@ -72,7 +78,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
// --- Configuration Properties (Arguments에서 로드) ---
private lateinit var contentsType: String
private var lastNumber: Int = 0
lateinit var webcontentsName: String
lateinit var webcontentsName: String
private lateinit var afterDot: String
private var useNumberInUrl: Boolean = true
private var enableGestures: Boolean = true
@ -102,7 +108,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
var canGoBack: Boolean? = null
protected lateinit var binding: BooktokiBinding
// var mPort: WebExtension.Port? = null
// var mPort: WebExtension.Port? = null
val mPortNam = "browser"
val extPath = "resource://android/assets/extensions/my_extension/"
val extId = "messaging@booktoki468.com"
@ -351,8 +357,52 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
// Implementation typically empty or same across fragments
}
private fun saveTextToPrivateVault(folderName: String, chapterTitle: String, content: String) {
CoroutineScope(Dispatchers.IO).launch {
try {
// 1. 최상위 보관함 폴더
val baseVaultDir = File(requireContext().getExternalFilesDir(null), "completed_torrents")
// 2. 요청받은 하위 폴더(folderName) 생성
val targetDir = File(baseVaultDir, folderName)
if (!targetDir.exists()) {
targetDir.mkdirs()
}
// 3. 파일명 생성 (파일 시스템에서 에러를 낼 수 있는 특수문자 \ / : * ? " < > | 자동 제거)
val safeTitle = chapterTitle.replace(Regex("[\\\\/:*?\"<>|]"), "_")
val fileName = if (safeTitle.endsWith(".txt", ignoreCase = true)) safeTitle else "$safeTitle.txt"
val destFile = File(targetDir, fileName)
// 4. 텍스트 파일 쓰기 (덮어쓰기)
destFile.writeText(content)
// 5. 완료 후 토스트 띄우기
withContext(Dispatchers.Main) {
showToast("[$folderName] 폴더에 저장됨: $fileName")
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
showToast("텍스트를 저장하지 못했습니다.")
}
}
}
}
override fun onLongClick() {
if (!enableGestures) return
if (contentsType.contains("book") &&
currentPage?.bookTitle?.isNotEmpty() == true &&
currentPage?.chapterTitle?.isNotEmpty() == true &&
binding.pagedLayer.text.isNotEmpty() == true && (binding.pagedLayer.text.length > 100)) {
currentPage?.bookTitle?.let { title ->
currentPage?.contents?.let { contents ->
saveTextToPrivateVault(title, "${this.currentChapter}_${this.currentTitle}",binding.pagedLayer.text)
}
}
}
Blog.LOGD(log = "onLongClick")
}
@ -479,6 +529,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
saveContinuation = false
if (hidden) {
// 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지
binding.lunaticBrowser.geckoWeb?.onPause()
@ -497,7 +548,6 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
): View {
Blog.LOGD(log = "onCreate ${this::class.java.name} >> savedInstanceState ${savedInstanceState}")
binding = BooktokiBinding.inflate(inflater)
binding = BooktokiBinding.inflate(inflater)
// 💡 1. Toki용 레이아웃 설정
binding.lunaticBrowser.setupForToki()
@ -652,11 +702,11 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
else -> {}
}
}
it.decoViews.clear()
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.tv_address))
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_back))
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_reload))
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_dl_video))
it.decoViews.clear()
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.tv_address))
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_back))
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_reload))
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_dl_video))
it.restoreSessionState()
}
@ -1051,6 +1101,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
fun actionNextEvent(fast: Boolean = false) {
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
binding.pagedLayer.doNext(fast)
binding.lunaticBrowser.geckoWeb.pageDown()
updateLastInfo(binding.pagedLayer!!)
} else {
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
@ -1087,6 +1138,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
fun actionPrevEvent(fast: Boolean = false) {
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && binding.pagedLayer.current() > 0) {
binding.pagedLayer.doPrev(fast)
binding.lunaticBrowser.geckoWeb.pageUp()
updateLastInfo(binding.pagedLayer)
} else {
moveToPrev(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
@ -1143,11 +1195,23 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
}
}
if (saveContinuation) {
binding.lunaticBrowser.geckoWeb.postDelayed({
moveToNext(
currentPage?.pathUrl ?: lastedUrl?.toUri()?.path
)
}, 10000)
CoroutineScope(Dispatchers.Main).launch {
binding.pagedLayer.visibility = View.INVISIBLE
binding.lunaticBrowser.visibility = View.VISIBLE
for (i in 0..24) {
if (binding.lunaticBrowser.geckoWeb.scrollState < 1) {
binding.lunaticBrowser.geckoWeb.pageDown()
}
delay(Random.nextLong(1500L, 3000L) + 1000L)
}
binding.lunaticBrowser.geckoWeb.postDelayed({
moveToNext(
currentPage?.pathUrl ?: lastedUrl?.toUri()?.path
)
binding.pagedLayer.visibility = View.INVISIBLE
binding.lunaticBrowser.visibility = View.VISIBLE
}, (Random.nextLong(1500L, 3000L) + 1000L))
}
}
}
}
@ -1168,11 +1232,12 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
binding.lunaticBrowser.binding.internalProgressBar.visibility = GONE
}, 1000)
}
applyReaderConfig()
activity?.runOnUiThread {
view.text = contents
view.visibility = VISIBLE
binding.lunaticBrowser.visibility = GONE
view.visibility = if (!saveContinuation) VISIBLE else GONE
binding.lunaticBrowser.visibility = if (saveContinuation) VISIBLE else GONE
}
// view.forceUpdateUI()
lastedUrl?.let {
@ -1181,6 +1246,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
HistoryManager.getBookPageInfo(contentsType,it) {
it?.let {
this@TokiFragment.currentPage = it
currentChapter = it?.chapterNum ?: 0
view.currentPage = it?.chapterNum ?: 0
HistoryManager.save(
@ -1194,8 +1260,29 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
HistoryManager.getBooPageInfoContentsSave(contentsType,it, contents)
}
if (saveContinuation) {
binding.lunaticBrowser.geckoWeb.postDelayed({moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)}, 10000)
onLongClick()
CoroutineScope(Dispatchers.Main).launch {
binding.pagedLayer.visibility = View.INVISIBLE
binding.lunaticBrowser.visibility = View.VISIBLE
for (i in 0..24) {
if (saveContinuation && binding.lunaticBrowser.geckoWeb.scrollState < 1) {
binding.lunaticBrowser.geckoWeb.pageDown()
}
if (saveContinuation && binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
binding.pagedLayer.doNext(false)
updateLastInfo(binding.pagedLayer!!)
}
if (saveContinuation) {
delay(Random.nextLong(1500L, 3000L) + 1000L)
}
}
if (saveContinuation) {
binding.pagedLayer.visibility = View.VISIBLE
binding.lunaticBrowser.visibility = View.VISIBLE
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
}
}
}
}
}
@ -1203,6 +1290,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
}
}
// Log.i(TAG, "onLoadedContents >> " + aContents)
}
@ -1229,24 +1317,95 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
) { dialog, which -> dialog.cancel() }
builder.show()
}
var testRegex = """[^0-9]""".toRegex()
Blog.LOGI(TAG, "onFindTitle >> " + contents + " ::: ${testRegex.replace(contents, "")}")
if (contents.contains("-")) {
currentTitle = contents.split("-")[0]
try {
currentChapter = testRegex.replace(contents.split("-")[1], "").toInt()
} catch (e: Exception) {
currentChapter = 0
// --- 여기서부터 파싱 로직 개선 ---
val cleanContents = contents.trim()
// 1. "숫자 + 화/편/회/장" 패턴을 찾습니다. (예: "1화 어더구", "소설제목 15화", "123편")
val chapterPattern = Regex("""(\d+)\s*(?:화|편|회|장)""")
val matchResult = chapterPattern.find(cleanContents)
if (matchResult != null) {
// 정규식으로 찾은 숫자 부분만 추출 (예: "15화" -> 15)
currentChapter = matchResult.groupValues[1].toInt()
// 매칭된 부분(예: "1화")을 기준으로 앞뒤를 분리합니다.
val beforeChapter = cleanContents.substring(0, matchResult.range.first).trim()
val afterChapter = cleanContents.substring(matchResult.range.last + 1).trim()
// "1화 어더구" 처럼 앞에 텍스트가 없으면 뒤에 있는 "어더구"를 타이틀로 사용
// "소설제목 1화" 처럼 앞에 텍스트가 있으면 앞부분을 타이틀로 사용
currentTitle = if (beforeChapter.isNotEmpty()) {
beforeChapter.trimEnd('-', ' ', ':')
} else if (afterChapter.isNotEmpty()) {
afterChapter.trimStart('-', ' ', ':')
} else {
"제목없음"
}
} else if (testRegex.replace(contents, "").length > 0) {
currentChapter = testRegex.replace(contents, "").toInt()
currentTitle = contents.split(testRegex.replace(contents, ""))[0]
} else {
val dateFormat = "yyyyMMdd-HH"
val date = Date(currentTimeMillis())
val simpleDateFormat = SimpleDateFormat(dateFormat)
currentTitle = simpleDateFormat.format(date)
}
// 2. "화" 같은 글자가 없고 하이픈(-)만 있는 경우 (예: "소설제목 - 15")
// 2. "화" 같은 글자가 없고 하이픈(-)만 있는 경우 (예: "소설제목 - 15" 또는 "15 - 소설제목")
else if (cleanContents.contains("-")) {
// 제목 자체에 하이픈이 들어있을 수 있으므로(예: "레벨-업 - 15") 가장 마지막 하이픈을 기준으로 자릅니다.
val lastDashIndex = cleanContents.lastIndexOf("-")
val frontPart = cleanContents.substring(0, lastDashIndex).trim()
val backPart = cleanContents.substring(lastDashIndex + 1).trim()
// 각 파트에서 순수 문자(한글/영어)만 남김 (길이 비교용)
val textOnlyFront = frontPart.replace(Regex("""[^a-zA-Z가-힣]"""), "")
val textOnlyBack = backPart.replace(Regex("""[^a-zA-Z가-힣]"""), "")
// 각 파트에서 숫자만 추출
val numFront = frontPart.replace(Regex("""[^0-9]"""), "")
val numBack = backPart.replace(Regex("""[^0-9]"""), "")
// 케이스 A: 뒷부분에 문자(글자)가 없고 숫자만 있다면 -> 앞이 제목, 뒤가 챕터 ("소설제목 - 15")
if (textOnlyBack.isEmpty() && numBack.isNotEmpty()) {
currentTitle = frontPart
currentChapter = numBack.toIntOrNull() ?: 0
}
// 케이스 B: 앞부분에 문자(글자)가 없고 숫자만 있다면 -> 앞이 챕터, 뒤가 제목 ("15 - 소설제목")
else if (textOnlyFront.isEmpty() && numFront.isNotEmpty()) {
currentTitle = backPart
currentChapter = numFront.toIntOrNull() ?: 0
}
// 케이스 C: 양쪽 다 문자가 섞여 있다면 ("시즌2 - 15(번외)") -> 문자가 더 긴 쪽을 제목으로 간주
else {
if (textOnlyFront.length >= textOnlyBack.length) {
currentTitle = frontPart
currentChapter = numBack.toIntOrNull() ?: 0
} else {
currentTitle = backPart
currentChapter = numFront.toIntOrNull() ?: 0
}
}
}
// 3. 위의 패턴에 모두 안 맞는 경우 (마지막 안전장치)
else {
val numberString = cleanContents.replace(Regex("""[^0-9]"""), "")
if (numberString.isNotEmpty()) {
try {
currentChapter = numberString.toInt()
currentTitle = cleanContents.replace(numberString, "").trim()
} catch (e: Exception) {
currentChapter = 0
currentTitle = cleanContents
}
} else {
// 아예 숫자가 없는 경우 타임스탬프 사용
currentChapter = 0
val dateFormat = "yyyyMMdd-HH"
val simpleDateFormat = SimpleDateFormat(dateFormat)
currentTitle = simpleDateFormat.format(Date(System.currentTimeMillis()))
}
}
// 최종적으로 타이틀이 비어있거나 기호만 남았다면 기본값 처리
if (currentTitle.isEmpty() || currentTitle.replace(Regex("""[^a-zA-Z0-9가-힣]"""), "").isEmpty()) {
currentTitle = "제목없음"
currentChapter = 0
}
Blog.LOGI(TAG, "onFindTitle Result >> Title: [$currentTitle], Chapter: [$currentChapter]")
}

View File

@ -177,7 +177,7 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
hanler.removeCallbacks(touchTimeover)
setOnLongClickListener { v ->
mPagedTextViewInterface?.onLongClick()
return@setOnLongClickListener false
return@setOnLongClickListener true
}
setOnTouchListener(SimpleFingerGestures(omfgl = object : SimpleFingerGestures.OnFingerGestureListener{
@ -240,7 +240,7 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
gestureDuration: Long,
gestureDistance: Double
): Boolean {
mPagedTextViewInterface?.onLongClick()
// mPagedTextViewInterface?.onLongClick()
return true
}
@ -249,9 +249,9 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
fingers: Int
): Boolean {
hanler.removeCallbacks(touchTimeover)
mPagedTextViewInterface?.onLongClick()
// mPagedTextViewInterface?.onLongClick()
hanler?.postDelayed(touchTimeover, 3000L)
return true
return false
}
override fun onLongPress(
@ -386,6 +386,7 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
}
}
fun doPrev(fast : Boolean = false) {
if (fast) {
setPageBy(if((this@PagedTextLayout.currentPage - getFastPageCount()) >= 0) {

View File

@ -2,6 +2,7 @@ package bums.lunatic.launcher.utils
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@ -96,4 +97,41 @@ object CompressStringUtil {
}
return bytes
}
}
class NaturalOrderComparator : Comparator<File> {
override fun compare(f1: File, f2: File): Int {
// 숫자와 문자의 경계를 기준으로 문자열을 쪼개는 정규식
val splitRegex = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)")
val parts1 = f1.name.split(splitRegex)
val parts2 = f2.name.split(splitRegex)
val limit = minOf(parts1.size, parts2.size)
for (i in 0 until limit) {
val p1 = parts1[i]
val p2 = parts2[i]
if (p1 == p2) continue
val isNum1 = p1.all { it.isDigit() }
val isNum2 = p2.all { it.isDigit() }
if (isNum1 && isNum2) {
// 둘 다 숫자면 앞의 0을 떼고 자리수 먼저 비교 후 문자열 비교
val num1 = p1.trimStart('0')
val num2 = p2.trimStart('0')
val cmp = if (num1.length != num2.length) {
num1.length.compareTo(num2.length)
} else {
num1.compareTo(num2)
}
if (cmp != 0) return cmp
} else {
// 숫자가 아니면 일반 문자열(대소문자 무시) 비교
val strCmp = p1.compareTo(p2, ignoreCase = true)
if (strCmp != 0) return strCmp
}
}
return parts1.size.compareTo(parts2.size)
}
}

View File

@ -81,10 +81,7 @@ class RuliWebGetter(context: Context) : BaseGetter(context) {
.header("pragma", "no-cache")
.ignoreContentType(true)
.get().let { ruli ->
// Blog.LOGE(TAG.plus("test ${testUrl2} >> ${ruli.title()}"))
ruli.getElementsByClass("table_body blocktarget").forEach { ruli_tr ->
parseRuli(ruli_tr)
}
ruli.getElementsByClass("table_body blocktarget").forEach { ruli_tr ->}
}
}
} catch (e:Exception){e.printStackTrace()}}

View File

@ -240,7 +240,7 @@ class TorrentService : Service() {
if (tasks.isNotEmpty()) {
updateNotification(tasks)
}
delay(1000)
delay(15000)
}
}
}

View File

@ -4,16 +4,6 @@
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/windowBackground">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="다운로드 보관함"
android:textSize="20sp"
android:textStyle="bold"
android:padding="16dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -21,6 +11,29 @@
android:paddingHorizontal="16dp"
android:paddingBottom="8dp"
android:gravity="center_vertical">
<TextView
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="다운로드 보관함"
android:textSize="20sp"
android:textStyle="bold"
android:padding="16dp"/>
<TextView
android:id="@+id/btnOrganize"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="auto_awesome" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingBottom="8dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/btnViewMode"
@ -31,19 +44,24 @@
<Space
android:layout_weight="1"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"/>
<Spinner
android:id="@+id/spinnerFilter"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Spinner
android:id="@+id/spinnerSort"
android:layout_width="0dp"
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/spinnerFilter"
android:layout_width="90dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
android:background="@android:color/transparent"
android:spinnerMode="dropdown"
/>
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/spinnerSort"
android:layout_width="90dp"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:spinnerMode="dropdown"
/>
<TextView
android:id="@+id/tvSortOrder"

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/text1"
android:layout_margin="10dp"
android:padding="10dp"
android:layout_margin="6dp"
android:padding="6dp"
android:gravity="center"
android:textColor="@color/bottom_option"
android:layout_height="match_parent"