...
This commit is contained in:
parent
d4ec1d5652
commit
406e7820bc
@ -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" />
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]")
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()}}
|
||||
|
||||
@ -240,7 +240,7 @@ class TorrentService : Service() {
|
||||
if (tasks.isNotEmpty()) {
|
||||
updateNotification(tasks)
|
||||
}
|
||||
delay(1000)
|
||||
delay(15000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user