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.BLUETOOTH" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission <uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS" android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" /> 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.databinding.LauncherActivityBinding
import bums.lunatic.launcher.feeds.WidgetHost import bums.lunatic.launcher.feeds.WidgetHost
import bums.lunatic.launcher.helpers.ForeGroundService 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.helpers.HeadsetActionButtonReceiver
import bums.lunatic.launcher.home.GeckoWeb import bums.lunatic.launcher.home.GeckoWeb
import bums.lunatic.launcher.home.NeoRssActivity import bums.lunatic.launcher.home.NeoRssActivity
@ -227,45 +231,73 @@ open class LauncherActivity : CommonActivity() {
// 💡 URI로부터 파일을 읽어서 프라이빗 폴더로 복사하는 함수 // 💡 URI로부터 파일을 읽어서 프라이빗 폴더로 복사하는 함수
private fun saveToPrivateVault(uri: Uri) { private fun saveToPrivateVault(uri: Uri) {
var fileName = "shared_${System.currentTimeMillis()}"
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
// 1. 원본 파일명 알아내기 // 1. 원본 파일명 및 총 파일 크기(용량) 알아내기
var fileName = "shared_${System.currentTimeMillis()}" var totalFileSize = 0L // 💡 파일 크기를 담을 변수 추가
contentResolver.query(uri, null, null, null, null)?.use { cursor -> contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) { if (nameIndex != -1) {
fileName = cursor.getString(nameIndex) 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") val vaultDir = File(getExternalFilesDir(null), "completed_torrents")
if (!vaultDir.exists()) vaultDir.mkdirs() if (!vaultDir.exists()) vaultDir.mkdirs()
// 2. 이름 중복 덮어쓰기 방지를 위해 타임스탬프 추가
val destFile = File(vaultDir, "${System.currentTimeMillis()}_$fileName") val destFile = File(vaultDir, "${System.currentTimeMillis()}_$fileName")
notiState("${destFile.name} 복사 시작",0)
// 3. 스트림 복사 (외부 파일 -> 내 프라이빗 폴더) // 3. 수동 스트림 복사 및 진행률 계산
contentResolver.openInputStream(uri)?.use { input -> contentResolver.openInputStream(uri)?.use { input ->
destFile.outputStream().use { output -> 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)
}
}
}
} }
} }
notiState("${destFile.name} ${100}% 복사 완료",100)
// 4. 완료 후 UI 스레드에서 토스트 띄우기
withContext(Dispatchers.Main) {
showToast("보관함에 저장되었습니다: $fileName")
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
withContext(Dispatchers.Main) { notiState("${fileName} 복사 실패 : ${e.message}",0)
showToast("파일을 보관함으로 복사하지 못했습니다.")
}
} }
} }
} }
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() { fun onSwipeLeft() {
showAppDrawer() showAppDrawer()
} }

View File

@ -49,7 +49,10 @@ import java.util.concurrent.TimeUnit
import android.bluetooth.BluetoothClass import android.bluetooth.BluetoothClass
import android.media.AudioManager import android.media.AudioManager
import android.os.SystemClock import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.KeyEvent import android.view.KeyEvent
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
class AggregatedSystemWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { class AggregatedSystemWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
@ -85,10 +88,13 @@ class ForeGroundService : Service() {
companion object { companion object {
val ACTION_SENDMSG = "ACTION_SEND_TO_LOVE" val ACTION_SENDMSG = "ACTION_SEND_TO_LOVE"
val EXTRA_MSGKEY = "SEND_MSG" val EXTRA_MSGKEY = "SEND_MSG"
val EXTRA_PROGRESS = "EXTRA_PROGRESS"
val ACTION_VIDEO_DOWNLOAD = "ACTION_YTURL_DOWNLOAD" val ACTION_VIDEO_DOWNLOAD = "ACTION_YTURL_DOWNLOAD"
val EXTRA_TARGET_URL = "ACTION_SEND_TO_LOVE" val EXTRA_TARGET_URL = "ACTION_SEND_TO_LOVE"
val ACTION_SIT_DOWN = "ACTION_SIT_DOWN" val ACTION_SIT_DOWN = "ACTION_SIT_DOWN"
val ACTION_COPY_COMPLETE = "ACTION_COPY_COMPLETE"
val targetUrls = arrayListOf<String>() val targetUrls = arrayListOf<String>()
} }
@ -108,7 +114,7 @@ class ForeGroundService : Service() {
val filter = IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED) val filter = IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED)
registerReceiver(bluetoothreceiver, filter) registerReceiver(bluetoothreceiver, filter)
refreshFeeds() refreshFeeds()
startForeGround() startForeGround(vibrator = true)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -137,7 +143,10 @@ class ForeGroundService : Service() {
sitDownRequest 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 -> { else -> {
@ -161,7 +170,7 @@ class ForeGroundService : Service() {
set(value) { set(value) {
field = value field = value
if (value == null) { if (value == null) {
startForeGround(0,0) startForeGround(max= 0, progress = 0, vibrator = true)
} }
} }
@ -181,7 +190,7 @@ class ForeGroundService : Service() {
currentProcessId = UUID.randomUUID().toString() currentProcessId = UUID.randomUUID().toString()
YoutubeDL.getInstance() YoutubeDL.getInstance()
.execute(command, currentProcessId) { progress, est, str -> .execute(command, currentProcessId) { progress, est, str ->
startForeGround(100, progress.toInt(),str) startForeGround(100, progress.toInt(),str, true)
if (progress >= 100) { if (progress >= 100) {
targetUrls.remove(url) targetUrls.remove(url)
currentProcessId = null currentProcessId = null
@ -201,17 +210,40 @@ class ForeGroundService : Service() {
} }
} }
fun startForeGround(max : Int = 0 , progress : Int = 0, str : String? = "실행중입니다.") { fun startForeGround(max : Int = 0, progress : Int = 0, str : String? = "실행중입니다.", vibrator : Boolean? = false) {
Blog.LOGE(str?:"")
val currentChannelId = if (vibrator == true) "${CHANNEL_ID}_vibrate" else "${CHANNEL_ID}_silent"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, currentChannelId,
"BUM'S 서비스", "BUM'S 서비스",
NotificationManager.IMPORTANCE_HIGH NotificationManager.IMPORTANCE_HIGH
) ).apply {
val manager = getSystemService(NotificationManager::class.java) if (vibrator == true) {
enableVibration(true)
vibrationPattern = longArrayOf(0, 500) // 0초 대기 후 0.5초 진동
} else {
enableVibration(false)
setSound(null, null) // 소리도 확실하게 제거
}
}
manager.createNotificationChannel(channel) 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 { val intent = Intent(this, LauncherActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP 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 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 서비스") .setContentTitle("BUM'S 서비스")
.setContentText(str) .setContentText(str)
.setPriority(NotificationCompat.PRIORITY_MAX) .setPriority(NotificationCompat.PRIORITY_MAX)
.setSmallIcon(R.drawable.ic_b) .setSmallIcon(R.drawable.ic_b)
.setContentIntent(pendingIntent) .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()) .addAction(android.R.drawable.ic_menu_directions, "앉음", makeSitDownAction())
.setOngoing(true) // 사용자가 알림을 스와이프로 지울 수 없게 만듦 .setOngoing(true)
.setProgress(max, progress, false) .setProgress(max, progress, false)
.build())
// 안드로이드 8.0 미만 기기를 위한 진동 처리
if (vibrator == true) {
builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE)
}
startForeground(NOTIF_ID, builder.build())
} }
fun makeSitDownAction(): PendingIntent { fun makeSitDownAction(): PendingIntent {

View File

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

View File

@ -48,6 +48,11 @@ import io.realm.kotlin.Realm
import io.realm.kotlin.UpdatePolicy import io.realm.kotlin.UpdatePolicy
import io.realm.kotlin.ext.copyFromRealm import io.realm.kotlin.ext.copyFromRealm
import io.realm.kotlin.ext.query 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.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.mozilla.gecko.util.ThreadUtils 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.WebExtension.PortDelegate
import org.mozilla.geckoview.WebExtensionController.AddonManagerDelegate import org.mozilla.geckoview.WebExtensionController.AddonManagerDelegate
import org.mozilla.geckoview.WebRequestError import org.mozilla.geckoview.WebRequestError
import java.io.File
import java.lang.System.currentTimeMillis import java.lang.System.currentTimeMillis
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -72,7 +78,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
// --- Configuration Properties (Arguments에서 로드) --- // --- Configuration Properties (Arguments에서 로드) ---
private lateinit var contentsType: String private lateinit var contentsType: String
private var lastNumber: Int = 0 private var lastNumber: Int = 0
lateinit var webcontentsName: String lateinit var webcontentsName: String
private lateinit var afterDot: String private lateinit var afterDot: String
private var useNumberInUrl: Boolean = true private var useNumberInUrl: Boolean = true
private var enableGestures: Boolean = true private var enableGestures: Boolean = true
@ -102,7 +108,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
var canGoBack: Boolean? = null var canGoBack: Boolean? = null
protected lateinit var binding: BooktokiBinding protected lateinit var binding: BooktokiBinding
// var mPort: WebExtension.Port? = null // var mPort: WebExtension.Port? = null
val mPortNam = "browser" val mPortNam = "browser"
val extPath = "resource://android/assets/extensions/my_extension/" val extPath = "resource://android/assets/extensions/my_extension/"
val extId = "messaging@booktoki468.com" val extId = "messaging@booktoki468.com"
@ -351,8 +357,52 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
// Implementation typically empty or same across fragments // 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() { override fun onLongClick() {
if (!enableGestures) return 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") Blog.LOGD(log = "onLongClick")
} }
@ -479,6 +529,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
override fun onHiddenChanged(hidden: Boolean) { override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden) super.onHiddenChanged(hidden)
saveContinuation = false
if (hidden) { if (hidden) {
// 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지 // 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지
binding.lunaticBrowser.geckoWeb?.onPause() binding.lunaticBrowser.geckoWeb?.onPause()
@ -497,7 +548,6 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
): View { ): View {
Blog.LOGD(log = "onCreate ${this::class.java.name} >> savedInstanceState ${savedInstanceState}") Blog.LOGD(log = "onCreate ${this::class.java.name} >> savedInstanceState ${savedInstanceState}")
binding = BooktokiBinding.inflate(inflater) binding = BooktokiBinding.inflate(inflater)
binding = BooktokiBinding.inflate(inflater)
// 💡 1. Toki용 레이아웃 설정 // 💡 1. Toki용 레이아웃 설정
binding.lunaticBrowser.setupForToki() binding.lunaticBrowser.setupForToki()
@ -652,11 +702,11 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
else -> {} else -> {}
} }
} }
it.decoViews.clear() it.decoViews.clear()
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.tv_address)) 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_back))
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_reload)) it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_reload))
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_dl_video)) it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_dl_video))
it.restoreSessionState() it.restoreSessionState()
} }
@ -1051,6 +1101,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
fun actionNextEvent(fast: Boolean = false) { fun actionNextEvent(fast: Boolean = false) {
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) { if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
binding.pagedLayer.doNext(fast) binding.pagedLayer.doNext(fast)
binding.lunaticBrowser.geckoWeb.pageDown()
updateLastInfo(binding.pagedLayer!!) updateLastInfo(binding.pagedLayer!!)
} else { } else {
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path) moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
@ -1087,6 +1138,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
fun actionPrevEvent(fast: Boolean = false) { fun actionPrevEvent(fast: Boolean = false) {
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && binding.pagedLayer.current() > 0) { if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && binding.pagedLayer.current() > 0) {
binding.pagedLayer.doPrev(fast) binding.pagedLayer.doPrev(fast)
binding.lunaticBrowser.geckoWeb.pageUp()
updateLastInfo(binding.pagedLayer) updateLastInfo(binding.pagedLayer)
} else { } else {
moveToPrev(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path) moveToPrev(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
@ -1143,11 +1195,23 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
} }
} }
if (saveContinuation) { if (saveContinuation) {
binding.lunaticBrowser.geckoWeb.postDelayed({ CoroutineScope(Dispatchers.Main).launch {
moveToNext( binding.pagedLayer.visibility = View.INVISIBLE
currentPage?.pathUrl ?: lastedUrl?.toUri()?.path binding.lunaticBrowser.visibility = View.VISIBLE
) for (i in 0..24) {
}, 10000) 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 binding.lunaticBrowser.binding.internalProgressBar.visibility = GONE
}, 1000) }, 1000)
} }
applyReaderConfig() applyReaderConfig()
activity?.runOnUiThread { activity?.runOnUiThread {
view.text = contents view.text = contents
view.visibility = VISIBLE view.visibility = if (!saveContinuation) VISIBLE else GONE
binding.lunaticBrowser.visibility = GONE binding.lunaticBrowser.visibility = if (saveContinuation) VISIBLE else GONE
} }
// view.forceUpdateUI() // view.forceUpdateUI()
lastedUrl?.let { lastedUrl?.let {
@ -1181,6 +1246,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
HistoryManager.getBookPageInfo(contentsType,it) { HistoryManager.getBookPageInfo(contentsType,it) {
it?.let { it?.let {
this@TokiFragment.currentPage = it this@TokiFragment.currentPage = it
currentChapter = it?.chapterNum ?: 0 currentChapter = it?.chapterNum ?: 0
view.currentPage = it?.chapterNum ?: 0 view.currentPage = it?.chapterNum ?: 0
HistoryManager.save( HistoryManager.save(
@ -1194,8 +1260,29 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
HistoryManager.getBooPageInfoContentsSave(contentsType,it, contents) HistoryManager.getBooPageInfoContentsSave(contentsType,it, contents)
} }
if (saveContinuation) { 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) // Log.i(TAG, "onLoadedContents >> " + aContents)
} }
@ -1229,24 +1317,95 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
) { dialog, which -> dialog.cancel() } ) { dialog, which -> dialog.cancel() }
builder.show() builder.show()
} }
var testRegex = """[^0-9]""".toRegex() // --- 여기서부터 파싱 로직 개선 ---
Blog.LOGI(TAG, "onFindTitle >> " + contents + " ::: ${testRegex.replace(contents, "")}") val cleanContents = contents.trim()
if (contents.contains("-")) {
currentTitle = contents.split("-")[0] // 1. "숫자 + 화/편/회/장" 패턴을 찾습니다. (예: "1화 어더구", "소설제목 15화", "123편")
try { val chapterPattern = Regex("""(\d+)\s*(?:화|편|회|장)""")
currentChapter = testRegex.replace(contents.split("-")[1], "").toInt() val matchResult = chapterPattern.find(cleanContents)
} catch (e: Exception) {
currentChapter = 0 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) hanler.removeCallbacks(touchTimeover)
setOnLongClickListener { v -> setOnLongClickListener { v ->
mPagedTextViewInterface?.onLongClick() mPagedTextViewInterface?.onLongClick()
return@setOnLongClickListener false return@setOnLongClickListener true
} }
setOnTouchListener(SimpleFingerGestures(omfgl = object : SimpleFingerGestures.OnFingerGestureListener{ setOnTouchListener(SimpleFingerGestures(omfgl = object : SimpleFingerGestures.OnFingerGestureListener{
@ -240,7 +240,7 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
gestureDuration: Long, gestureDuration: Long,
gestureDistance: Double gestureDistance: Double
): Boolean { ): Boolean {
mPagedTextViewInterface?.onLongClick() // mPagedTextViewInterface?.onLongClick()
return true return true
} }
@ -249,9 +249,9 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
fingers: Int fingers: Int
): Boolean { ): Boolean {
hanler.removeCallbacks(touchTimeover) hanler.removeCallbacks(touchTimeover)
mPagedTextViewInterface?.onLongClick() // mPagedTextViewInterface?.onLongClick()
hanler?.postDelayed(touchTimeover, 3000L) hanler?.postDelayed(touchTimeover, 3000L)
return true return false
} }
override fun onLongPress( override fun onLongPress(
@ -386,6 +386,7 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
} }
} }
fun doPrev(fast : Boolean = false) { fun doPrev(fast : Boolean = false) {
if (fast) { if (fast) {
setPageBy(if((this@PagedTextLayout.currentPage - getFastPageCount()) >= 0) { 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.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
@ -96,4 +97,41 @@ object CompressStringUtil {
} }
return bytes 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") .header("pragma", "no-cache")
.ignoreContentType(true) .ignoreContentType(true)
.get().let { ruli -> .get().let { ruli ->
// Blog.LOGE(TAG.plus("test ${testUrl2} >> ${ruli.title()}")) ruli.getElementsByClass("table_body blocktarget").forEach { ruli_tr ->}
ruli.getElementsByClass("table_body blocktarget").forEach { ruli_tr ->
parseRuli(ruli_tr)
}
} }
} }
} catch (e:Exception){e.printStackTrace()}} } catch (e:Exception){e.printStackTrace()}}

View File

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

View File

@ -4,16 +4,6 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:background="?android:attr/windowBackground"> 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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -21,6 +11,29 @@
android:paddingHorizontal="16dp" android:paddingHorizontal="16dp"
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:gravity="center_vertical"> 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 <TextView
android:id="@+id/btnViewMode" android:id="@+id/btnViewMode"
@ -31,19 +44,24 @@
<Space <Space
android:layout_weight="1" android:layout_weight="1"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content"/> android:layout_height="wrap_content"/>
<Spinner
android:id="@+id/spinnerFilter"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Spinner <androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/spinnerSort" android:id="@+id/spinnerFilter"
android:layout_width="0dp" android:layout_width="90dp"
android:layout_height="wrap_content" 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 <TextView
android:id="@+id/tvSortOrder" android:id="@+id/tvSortOrder"

View File

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