This commit is contained in:
lunaticbum 2026-03-20 17:55:48 +09:00
parent 406e7820bc
commit 3f0a7d0d5c
9 changed files with 598 additions and 192 deletions

View File

@ -55,6 +55,36 @@ port.onMessage.addListener(response => {
var type= response["type"]; var type= response["type"];
switch (type) { switch (type) {
case "CLICK_PREV_CHAPTER" : {
const links = document.querySelectorAll("a");
let found = false;
for (let link of links) {
if (link.textContent.includes("이전화 보기")) {
link.click();
found = true;
break;
}
}
if (!found) {
port.postMessage({ type: "MSG", msg: "이전화 버튼을 찾을 수 없습니다." });
}
break;
}
case "CLICK_NEXT_CHAPTER" : {
const links = document.querySelectorAll("a");
let found = false;
for (let link of links) {
if (link.textContent.includes("다음화 보기")) {
link.click();
found = true;
break;
}
}
if (!found) {
port.postMessage({ type: "MSG", msg: "다음화 버튼을 찾을 수 없습니다." });
}
break;
}
case "fetchAllImages": { case "fetchAllImages": {
const targetSrc = response["targetSrc"]; const targetSrc = response["targetSrc"];
const imageUrls = findAllRelatedImages(targetSrc); const imageUrls = findAllRelatedImages(targetSrc);

View File

@ -3,10 +3,10 @@ package bums.lunatic.launcher.apps
import android.app.Dialog import android.app.Dialog
import android.app.SearchManager import android.app.SearchManager
import android.content.Intent import android.content.Intent
import android.content.pm.ApplicationInfo
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore
import android.view.KeyEvent import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -21,23 +21,17 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import bums.lunatic.launcher.BuildConfig import bums.lunatic.launcher.BuildConfig
import bums.lunatic.launcher.R import bums.lunatic.launcher.R
import bums.lunatic.launcher.apps.AppMenu import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding // XML 이름에 맞춰 바인딩 클래스 생성됨
import bums.lunatic.launcher.helpers.PrefBoolean
import bums.lunatic.launcher.helpers.PrefLong
import bums.lunatic.launcher.model.AppInfo import bums.lunatic.launcher.model.AppInfo
import bums.lunatic.launcher.model.SimpleContact import bums.lunatic.launcher.model.SimpleContact
import bums.lunatic.launcher.utils.Blog import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.CategoryGrouper import bums.lunatic.launcher.utils.CategoryGrouper
import bums.lunatic.launcher.utils.CategoryManualMapper import bums.lunatic.launcher.utils.CategoryManualMapper
import bums.lunatic.launcher.utils.EnToKo
import bums.lunatic.launcher.utils.JamoUtils
import bums.lunatic.launcher.utils.SimpleTransliterater import bums.lunatic.launcher.utils.SimpleTransliterater
import bums.lunatic.launcher.workers.UsageLogType import bums.lunatic.launcher.workers.UsageLogType
import bums.lunatic.launcher.workers.UsageUpdateType import bums.lunatic.launcher.workers.UsageUpdateType
import bums.lunatic.launcher.workers.WorkersDb import bums.lunatic.launcher.workers.WorkersDb
import com.google.android.gms.common.wrappers.PackageManagerWrapper import com.google.android.gms.common.wrappers.PackageManagerWrapper
import com.google.android.gms.common.wrappers.Wrappers.packageManager
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
@ -47,7 +41,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.net.URLEncoder import java.net.URLEncoder
import kotlin.math.min
class AppDrawerBottomSheet : BottomSheetDialogFragment() { class AppDrawerBottomSheet : BottomSheetDialogFragment() {
@ -252,23 +245,31 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
filterAppsList(keyword) filterAppsList(keyword)
} }
binding.searchTaxi.setOnClickListener { binding.searchTaxi.setOnClickListener {
//// val destName = Uri.encode("서울역")
//// val url = "kakaot://taxi?dest_lat=${URLEncoder.encode("37.467696")}&dest_lng=${URLEncoder.encode("127.101063")}&dest_name=세곡동557"
// val url = "kakaomap://search?q=세곡동 557"
// val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
// startActivity(intent)
openSearchApps("kakaot://taxi?goalx=${URLEncoder.encode("37.467696")}&goaly=${URLEncoder.encode("127.101063")}","com.kakao.taxi") openSearchApps("kakaot://taxi?goalx=${URLEncoder.encode("37.467696")}&goaly=${URLEncoder.encode("127.101063")}","com.kakao.taxi")
} }
binding.searchMusic.setOnClickListener { binding.searchMusic.setOnClickListener {
try { try {
// 스포티파이는 별도의 URL 인코딩 없이도 잘 동작하지만, 안전하게 인코딩 추천 val intent: Intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH)
val encodedKeyword = URLEncoder.encode(keyword, "UTF-8")
val uri = Uri.parse("spotify:search:$encodedKeyword")
val intent = Intent(Intent.ACTION_VIEW, uri) intent.putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/*")
// 명시적으로 스포티파이 앱 지정 (웹 브라우저로 빠지는 것 방지) intent.putExtra(SearchManager.QUERY, keyword) // 검색하고자 하는 곡명이나 아티스트명
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
requireContext().startActivity(intent) intent.setPackage("com.apple.android.music") // 애플 뮤직 패키지 명시
if (intent.resolveActivity(requireContext().getPackageManager()) != null) {
startActivity(intent)
}
} catch (e: Exception) { } catch (e: Exception) {
// 앱이 없을 경우 플레이스토어로 이동 // 앱이 없을 경우 플레이스토어로 이동
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.spotify.music")) val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.apple.android.music"))
marketIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK marketIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
requireContext().startActivity(marketIntent) requireContext().startActivity(marketIntent)
} }
@ -298,7 +299,7 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
binding.searchNaver.setOnClickListener { binding.searchNaver.setOnClickListener {
openSearchApps("https://search.naver.com/search.naver?where=nexearch&query=${keyword}", "com.nhn.android.search") openSearchApps("https://search.naver.com/search.naver?where=nexearch&query=${keyword}", "com.nhn.android.search")
} }
// ... 나머지 버튼들도 동일하게 추가 ...
} }

View File

@ -3,6 +3,7 @@ package bums.lunatic.launcher.home
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -22,7 +23,6 @@ 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.NaturalOrderComparator
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
@ -34,6 +34,36 @@ enum class FileFilterType(val label : String) { ALL("전체"), IMAGE("이미지"
enum class FileSortType(val label : String) { NAME("파일명") , 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 }
// 💡 숫자 크기를 인식하는 정렬 비교자
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) {
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)
}
}
class CompletedFilesFragment : Fragment() { class CompletedFilesFragment : Fragment() {
private lateinit var recyclerView: RecyclerView private lateinit var recyclerView: RecyclerView
@ -48,12 +78,16 @@ class CompletedFilesFragment : Fragment() {
private var currentViewMode = FileViewMode.LIST_TEXT private var currentViewMode = FileViewMode.LIST_TEXT
private var isDescending = true private var isDescending = true
// 💡 멀티 선택 모드 상태 관리 변수
private var isSelectionMode = false
private val selectedFiles = mutableSetOf<File>()
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 private lateinit var backPressedCallback: OnBackPressedCallback
fun backPress() = backPressedCallback.handleOnBackPressed()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
@ -70,7 +104,8 @@ class CompletedFilesFragment : Fragment() {
backPressedCallback = object : OnBackPressedCallback(false) { backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
navigateUp() // 선택 모드일 땐 선택 모드 먼저 해제, 아닐 땐 상위 폴더 이동
if (isSelectionMode) toggleSelectionMode(false) else navigateUp()
} }
} }
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback) requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback)
@ -80,40 +115,61 @@ class CompletedFilesFragment : Fragment() {
loadFiles() loadFiles()
} }
// 💡 선택 모드 진입/해제 UI 토글
private fun toggleSelectionMode(enable: Boolean) {
isSelectionMode = enable
if (!enable) selectedFiles.clear()
adapter.updateSelection(selectedFiles)
// 추가한 XML 뷰 보이기/숨기기
view?.findViewById<View>(R.id.layoutSelectionActions)?.visibility = if (enable) View.VISIBLE else View.GONE
// 뒤로가기 버튼 활성화 기준 업데이트
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
}
private fun setupRecyclerView(view: View) { private fun setupRecyclerView(view: View) {
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles) recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
updateRecyclerViewLayoutManager() updateRecyclerViewLayoutManager()
// 💡 어댑터 생성 시 getAccessCount 함수를 넘겨주어 어댑터 내부에서 조회수를 그릴 수 있게 합니다.
adapter = CompletedFilesAdapter( adapter = CompletedFilesAdapter(
onItemClick = { file -> onItemClick = { file ->
if (file.name == "..") { if (file.name == "..") {
navigateUp() if (!isSelectionMode) navigateUp()
} else if (file.isDirectory) { return@CompletedFilesAdapter
currentDir = file }
loadFiles()
} else { // 💡 멀티 선택 모드일 때의 클릭 로직
openPrivateFile(requireContext(), file) if (isSelectionMode) {
if (selectedFiles.contains(file)) selectedFiles.remove(file) else selectedFiles.add(file)
if (selectedFiles.isEmpty()) toggleSelectionMode(false)
else adapter.updateSelection(selectedFiles)
}
// 일반 모드일 때의 클릭 로직
else {
if (file.isDirectory) {
currentDir = file
loadFiles()
} else {
openPrivateFile(requireContext(), file)
}
} }
}, },
onItemLongClick = { file -> onItemLongClick = { file ->
val type = if (file.isDirectory) "폴더" else "파일" if (file.name == "..") return@CompletedFilesAdapter
android.app.AlertDialog.Builder(context)
.setTitle("$type 삭제") // 💡 폴더를 길게 누르면 -> 폴더 상세 삭제 다이얼로그 호출
.setMessage("해당 ${type}를 삭제하시겠습니까?\n(폴더일 경우 내부 파일도 모두 삭제됩니다)") if (file.isDirectory && !isSelectionMode) {
.setPositiveButton("확인") { _, _ -> showFolderDeleteDialog(file)
CoroutineScope(Dispatchers.IO).launch { }
val success = if (file.isDirectory) file.deleteRecursively() else file.delete() // 💡 파일을 길게 누르면 -> 멀티 선택 모드 시작
if (success) { else if (!isSelectionMode) {
withContext(Dispatchers.Main) { toggleSelectionMode(true)
Toast.makeText(requireContext(), "삭제되었습니다.", Toast.LENGTH_SHORT).show() selectedFiles.add(file)
loadFiles() adapter.updateSelection(selectedFiles)
} }
}
}
}
.setNegativeButton("취소", null)
.show()
}, },
getAccessCount = { fileName -> getAccessCount(fileName) } getAccessCount = { fileName -> getAccessCount(fileName) }
) )
@ -121,13 +177,223 @@ class CompletedFilesFragment : Fragment() {
recyclerView.adapter = adapter recyclerView.adapter = adapter
} }
private fun navigateUp() { // 💡 폴더 내부 확인 및 개별 삭제 다이얼로그
if (currentDir.absolutePath != rootDir.absolutePath) { private fun showFolderDeleteDialog(folder: File) {
currentDir = currentDir.parentFile ?: rootDir val files = folder.listFiles()?.filter { it.isFile }?.sortedWith(NaturalOrderComparator()) ?: emptyList()
loadFiles()
if (files.isEmpty()) {
android.app.AlertDialog.Builder(requireContext())
.setTitle("폴더 삭제")
.setMessage("빈 폴더입니다. 삭제하시겠습니까?")
.setPositiveButton("확인") { _, _ ->
folder.delete()
Toast.makeText(context, "폴더가 삭제되었습니다.", Toast.LENGTH_SHORT).show()
loadFiles()
}
.setNegativeButton("취소", null)
.show()
return
} }
val fileNames = files.map { file ->
val sizeMb = file.length() / (1024.0 * 1024.0)
val accessCount = getAccessCount(file.name)
String.format("%s\n(%.2f MB, 조회 %d회)", file.name, sizeMb, accessCount)
}.toTypedArray()
val checkedItems = BooleanArray(files.size) { false }
android.app.AlertDialog.Builder(requireContext())
.setTitle("📁 ${folder.name} 정리")
.setMultiChoiceItems(fileNames, checkedItems) { _, which, isChecked ->
checkedItems[which] = isChecked
}
.setPositiveButton("선택 항목 삭제") { _, _ ->
var deletedCount = 0
files.forEachIndexed { index, file ->
if (checkedItems[index] && file.delete()) {
deletedCount++
}
}
Toast.makeText(context, "${deletedCount}개의 파일이 삭제되었습니다.", Toast.LENGTH_SHORT).show()
loadFiles()
}
.setNeutralButton("폴더 전체 삭제") { _, _ ->
// 💡 여기서도 다이얼로그 안에서 다이얼로그를 부릅니다! (안전한 패턴)
android.app.AlertDialog.Builder(requireContext())
.setMessage("정말로 폴더와 내부의 모든 파일을 삭제하시겠습니까?")
.setPositiveButton("") { _, _ ->
folder.deleteRecursively()
loadFiles()
}
.setNegativeButton("아니오", null)
.show()
}
// 💡 기존의 '취소' 자리를 '선택 이동'으로 사용
.setNegativeButton("선택 항목 이동") { _, _ ->
// 체크된 파일들만 필터링해서 리스트로 만듦
val selectedFilesToMove = files.filterIndexed { index, _ -> checkedItems[index] }
if (selectedFilesToMove.isNotEmpty()) {
// 두 번째 다이얼로그(이동 창) 호출
showMoveDialogForFiles(selectedFilesToMove)
} else {
Toast.makeText(context, "선택된 항목이 없습니다.", Toast.LENGTH_SHORT).show()
}
}
.setCancelable(true) // 빈 화면을 터치하거나 뒤로가기 키를 누르면 다이얼로그가 취소(닫힘)됨
.show()
} }
private fun showUnorganizedFilesDialog(filterType: FileFilterType) {
// 사용자가 제외하고 싶어 하는 타겟 목록
val excludedDirs = listOf(rootDir.name, "이미지", "비디오", "문서", "기타")
val unorganizedFiles = mutableListOf<File>()
rootDir.walkTopDown().onEnter { dir ->
// 1. 루트 폴더 자체는 통과해야 하위 폴더를 검사할 수 있음
if (dir == rootDir) return@onEnter true
// 2. 루트 바로 아래에 있는 '이미지, 비디오, 문서, 기타' 폴더는 검사 진입 거부!
if (dir.parentFile == rootDir && excludedDirs.contains(dir.name)) {
return@onEnter false
}
true
}.forEach { file ->
if (file.isFile) {
// 3. 루트 폴더 바로 아래에 널브러진 파일도 제외하고 싶다면 (rootDir.name 이 target에 있으므로)
if (file.parentFile == rootDir && excludedDirs.contains(rootDir.name)) return@forEach
val ext = file.extension.lowercase()
val isMatch = when (filterType) {
FileFilterType.ALL -> true
FileFilterType.IMAGE -> extImages.contains(ext)
FileFilterType.VIDEO -> extVideos.contains(ext)
FileFilterType.DOCUMENT -> extDocs.contains(ext)
FileFilterType.OTHER -> !extImages.contains(ext) && !extVideos.contains(ext) && !extDocs.contains(ext)
}
if (isMatch) unorganizedFiles.add(file)
}
}
if (unorganizedFiles.isEmpty()) {
Toast.makeText(context, "정리할 ${filterType.label} 파일이 없습니다.", Toast.LENGTH_SHORT).show()
return
}
// 파일 정렬 (내추럴 정렬 사용)
val sortedFiles = unorganizedFiles.sortedWith(NaturalOrderComparator())
val checkedItems = BooleanArray(sortedFiles.size) { false }
// 리스트에 보여줄 텍스트 가공 (어느 폴더에 있던 파일인지 알기 쉽게 [폴더명] 표시)
val fileNames = sortedFiles.map { file ->
val sizeMb = file.length() / (1024.0 * 1024.0)
"📁 [${file.parentFile?.name}]\n📄 ${file.name} (%.2f MB)".format(sizeMb)
}.toTypedArray()
android.app.AlertDialog.Builder(requireContext())
.setTitle("흩어진 ${filterType.label} 파일 모아보기")
.setMultiChoiceItems(fileNames, checkedItems) { _, which, isChecked ->
checkedItems[which] = isChecked
}
.setPositiveButton("선택 항목 삭제") { _, _ ->
val filesToDelete = sortedFiles.filterIndexed { index, _ -> checkedItems[index] }
if (filesToDelete.isEmpty()) return@setPositiveButton
var delCount = 0
filesToDelete.forEach { if (it.delete()) delCount++ }
Toast.makeText(context, "${delCount}개의 파일이 삭제되었습니다.", Toast.LENGTH_SHORT).show()
loadFiles()
}
.setNegativeButton("선택 항목 이동") { _, _ ->
val filesToMove = sortedFiles.filterIndexed { index, _ -> checkedItems[index] }
if (filesToMove.isNotEmpty()) {
// 이전 답변에서 만든 다이얼로그 재활용!
showMoveDialogForFiles(filesToMove)
} else {
Toast.makeText(context, "선택된 항목이 없습니다.", Toast.LENGTH_SHORT).show()
}
}
.setNeutralButton("취소", null)
.show()
}
private fun showMoveDialogForFiles(filesToMove: List<File>) {
// 이동 가능한 디렉토리 목록 수집 (현재 폴더는 뺄 필요가 있다면 필터링 로직 추가 가능)
var target = arrayListOf<String>(rootDir.name,"이미지",
"비디오",
"문서",
"기타")
val folders = rootDir.listFiles()?.filter { it.isDirectory && target.contains(it.name) }?.toMutableList() ?: mutableListOf()
// 루트 폴더로 꺼내는 옵션도 추가
folders.add(0, rootDir)
if (folders.isEmpty()) {
Toast.makeText(context, "이동할 대상 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
return
}
val folderNames = folders.map {
if (it.absolutePath == rootDir.absolutePath) "📁 [최상위 폴더로 꺼내기]" else "📁 ${it.name}"
}.toTypedArray()
android.app.AlertDialog.Builder(requireContext())
.setTitle("${filesToMove.size}개 항목 이동 대상 선택")
.setItems(folderNames) { _, which ->
val targetFolder = folders[which]
var moveCount = 0
filesToMove.forEach { file ->
val targetFile = File(targetFolder, file.name)
// 이름이 겹치지 않고 이동에 성공했다면 카운트 증가
if (file.renameTo(targetFile)) {
moveCount++
}
}
Toast.makeText(context, "${moveCount}개 항목 이동 완료", Toast.LENGTH_SHORT).show()
loadFiles()
}
.setNegativeButton("취소", null)
.show()
}
// 💡 선택한 파일 이동 다이얼로그
private fun showMoveDialog() {
if (selectedFiles.isEmpty()) return
// 이동 가능한 디렉토리 목록 수집 (루트 폴더 + 자식 폴더들)
val folders = rootDir.listFiles()?.filter { it.isDirectory }?.toMutableList() ?: mutableListOf()
if (currentDir.absolutePath != rootDir.absolutePath) {
folders.add(0, rootDir) // 하위 폴더일 경우 루트로 뺄 수 있게 옵션 추가
}
folders.removeAll { it.absolutePath == currentDir.absolutePath } // 현재 폴더는 타겟에서 제외
if (folders.isEmpty()) {
Toast.makeText(context, "이동할 대상 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
return
}
val folderNames = folders.map { if (it.absolutePath == rootDir.absolutePath) "📁 [최상위 폴더로 꺼내기]" else "📁 ${it.name}" }.toTypedArray()
android.app.AlertDialog.Builder(requireContext())
.setTitle("${selectedFiles.size}개 항목 이동 대상 선택")
.setItems(folderNames) { _, which ->
val targetFolder = folders[which]
var moveCount = 0
selectedFiles.forEach { file ->
val targetFile = File(targetFolder, file.name)
if (file.renameTo(targetFile)) moveCount++
}
Toast.makeText(context, "${moveCount}개 항목 이동 완료", Toast.LENGTH_SHORT).show()
toggleSelectionMode(false)
loadFiles()
}
.setNegativeButton("취소", null)
.show()
}
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"
@ -136,19 +402,36 @@ class CompletedFilesFragment : Fragment() {
FileViewMode.GRID_SMALL -> "apps" FileViewMode.GRID_SMALL -> "apps"
} }
} }
private fun setupControls(view: View) { private fun setupControls(view: View) {
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) val btnOrganize = view.findViewById<TextView>(R.id.btnOrganize)
btnOrganize?.setOnClickListener {
organizeRootFiles() // 💡 선택 액션 바 버튼 이벤트
view.findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
view.findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
view.findViewById<View>(R.id.btnDeleteSelected)?.setOnClickListener {
if (selectedFiles.isEmpty()) return@setOnClickListener
android.app.AlertDialog.Builder(requireContext())
.setMessage("선택한 ${selectedFiles.size}개 항목을 삭제하시겠습니까?")
.setPositiveButton("삭제") { _, _ ->
var delCount = 0
selectedFiles.forEach { file ->
if (file.isDirectory) { file.deleteRecursively(); delCount++ }
else if (file.delete()) { delCount++ }
}
Toast.makeText(context, "${delCount}개 항목 삭제됨", Toast.LENGTH_SHORT).show()
toggleSelectionMode(false)
loadFiles()
}
.setNegativeButton("취소", null)
.show()
} }
btnOrganize?.setOnClickListener { organizeRootFiles() }
btnViewMode.text = getViewModeSymbolName(currentViewMode) btnViewMode.text = getViewModeSymbolName(currentViewMode)
btnViewMode.setOnClickListener { btnViewMode.setOnClickListener {
currentViewMode = when (currentViewMode) { currentViewMode = when (currentViewMode) {
@ -163,10 +446,29 @@ class CompletedFilesFragment : Fragment() {
} }
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).apply { // spinnerFilter.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions)
setDropDownViewResource(R.layout.spinner_item_dark) spinnerFilter.adapter = object : ArrayAdapter<String>(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions) {
} override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getDropDownView(position, convertView, parent)
// 💡 드롭다운 안의 아이템에 롱클릭 리스너 장착
view.setOnLongClickListener {
val selectedFilterType = FileFilterType.values()[position]
// 롱클릭 시 방금 만든 다이얼로그 호출!
showUnorganizedFilesDialog(selectedFilterType)
// 다이얼로그가 뜨면 뒤에 열려있는 스피너 드롭다운 목록을 닫기 위해
// 시스템 뒤로가기 키 이벤트를 가짜로 발생시킵니다.
val root = spinnerFilter.rootView
root.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK))
root.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK))
true // true를 반환해야 일반 클릭(필터 적용)이 무시됩니다.
}
return view
}
}
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]
@ -175,11 +477,8 @@ class CompletedFilesFragment : Fragment() {
override fun onNothingSelected(p0: AdapterView<*>?) {} override fun onNothingSelected(p0: AdapterView<*>?) {}
} }
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).apply { spinnerSort.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, sortOptions)
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]
@ -196,17 +495,13 @@ class CompletedFilesFragment : Fragment() {
} }
} }
// 💡 루트 경로에 있는 파일들을 각각의 폴더로 이동시키는 자동 정리 함수
private fun organizeRootFiles() { 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 { CoroutineScope(Dispatchers.IO).launch {
// 1. 루트 폴더의 파일 목록 가져오기
val filesInRoot = rootDir.listFiles()?.filter { it.isFile } ?: emptyList()
var movedCount = 0
// 2. 널브러진 파일들을 확장자별 폴더로 이동
filesInRoot.forEach { file -> filesInRoot.forEach { file ->
val ext = file.extension.lowercase() val ext = file.extension.lowercase()
val folderName = when { val folderName = when {
@ -215,38 +510,59 @@ class CompletedFilesFragment : Fragment() {
extDocs.contains(ext) -> "문서" extDocs.contains(ext) -> "문서"
else -> "기타" else -> "기타"
} }
val targetDir = File(rootDir, folderName) val targetDir = File(rootDir, folderName)
if (!targetDir.exists()) targetDir.mkdirs() if (!targetDir.exists()) targetDir.mkdirs()
val targetFile = File(targetDir, file.name) if (file.renameTo(File(targetDir, file.name))) {
// 동일한 이름의 파일이 이미 있다면 덮어쓰거나 이동 실패할 수 있으므로 주의 (필요시 중복 네이밍 처리)
if (file.renameTo(targetFile)) {
movedCount++ movedCount++
} }
} }
withContext(Dispatchers.Main) { // 💡 3. 빈 폴더 싹쓸이 기능 추가 (Bottom-Up 방식)
if (movedCount > 0) { var deletedFolderCount = 0
Toast.makeText(requireContext(), "${movedCount}개의 파일을 자동 정리했습니다.", Toast.LENGTH_SHORT).show()
// 만약 사용자가 지금 루트 폴더를 보고 있다면 리스트를 즉시 갱신 // walkBottomUp()을 사용하면 가장 깊은 하위 폴더부터 위로 올라오면서 검사합니다.
if (currentDir.absolutePath == rootDir.absolutePath) { rootDir.walkBottomUp().forEach { dir ->
loadFiles() // 최상위 루트 폴더 자체는 지우면 안 되므로 통과
if (dir != rootDir && dir.isDirectory) {
// 폴더 내부에 아무것도 없다면 삭제
if (dir.listFiles()?.isEmpty() == true) {
if (dir.delete()) {
deletedFolderCount++
}
} }
}
}
// 4. 메인 스레드에서 결과 메시지 출력 및 리스트 갱신
withContext(Dispatchers.Main) {
if (movedCount > 0 || deletedFolderCount > 0) {
val msg = buildString {
if (movedCount > 0) append("${movedCount}개의 파일을 폴더로 정리했습니다.\n")
if (deletedFolderCount > 0) append("${deletedFolderCount}개의 빈 폴더를 삭제했습니다.")
}.trim()
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
// 파일 이동이나 폴더 삭제가 하나라도 일어났다면 현재 화면 새로고침
loadFiles()
} else { } else {
Toast.makeText(requireContext(), "파일 정리에 실패했습니다.", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "정리할 파일이나 빈 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
} }
} }
} }
} }
private fun loadFiles() { private fun navigateUp() {
if (currentDir.exists()) { if (currentDir.absolutePath != rootDir.absolutePath) {
allFiles = currentDir.listFiles()?.toList() ?: emptyList() currentDir = currentDir.parentFile ?: rootDir
} else { loadFiles()
allFiles = emptyList()
} }
backPressedCallback.isEnabled = currentDir.absolutePath != rootDir.absolutePath }
private fun loadFiles() {
allFiles = if (currentDir.exists()) currentDir.listFiles()?.toList() ?: emptyList() else emptyList()
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
applyFilterAndSort() applyFilterAndSort()
} }
@ -269,37 +585,19 @@ 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() val naturalComparator = NaturalOrderComparator()
val sortedFolders = folders.sortedBy { it.name.lowercase() } val sortedFolders = folders.sortedWith(naturalComparator)
val sortedFiles = when (currentSort) { val sortedFiles = when (currentSort) {
FileSortType.DOWNLOAD_DATE -> { FileSortType.DOWNLOAD_DATE -> if (isDescending) files.sortedByDescending { it.lastModified() } else files.sortedBy { it.lastModified() }
if (isDescending) files.sortedByDescending { it.lastModified() } FileSortType.LAST_USED -> if (isDescending) files.sortedByDescending { getLastAccessedTime(it.name) } else files.sortedBy { getLastAccessedTime(it.name) }
else files.sortedBy { it.lastModified() } FileSortType.SIZE -> if (isDescending) files.sortedByDescending { it.length() } else files.sortedBy { it.length() }
} FileSortType.FREQUENTLY_USED -> if (isDescending) files.sortedByDescending { getAccessCount(it.name) } else files.sortedBy { getAccessCount(it.name) }
FileSortType.LAST_USED -> { FileSortType.NAME -> if (isDescending) files.sortedWith(naturalComparator.reversed()) else files.sortedWith(naturalComparator)
if (isDescending) files.sortedByDescending { getLastAccessedTime(it.name) }
else files.sortedBy { getLastAccessedTime(it.name) }
}
FileSortType.SIZE -> {
if (isDescending) files.sortedByDescending { it.length() }
else files.sortedBy { it.length() }
}
FileSortType.FREQUENTLY_USED -> {
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)
}
} }
val finalItems = mutableListOf<File>() val finalItems = mutableListOf<File>()
if (currentDir.absolutePath != rootDir.absolutePath) finalItems.add(File(currentDir, ".."))
if (currentDir.absolutePath != rootDir.absolutePath) {
finalItems.add(File(currentDir, ".."))
}
finalItems.addAll(sortedFolders) finalItems.addAll(sortedFolders)
finalItems.addAll(sortedFiles) finalItems.addAll(sortedFiles)
@ -308,24 +606,13 @@ class CompletedFilesFragment : Fragment() {
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)
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", prefs.getInt("${fileName}_count", 0) + 1)
.apply() .apply()
} }
private fun getLastAccessedTime(fileName: String) = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE).getLong(fileName, 0L)
private fun getLastAccessedTime(fileName: String): Long { private fun getAccessCount(fileName: String) = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE).getInt("${fileName}_count", 0)
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)
}
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()) ?: "*/*"
@ -334,20 +621,13 @@ class CompletedFilesFragment : Fragment() {
private fun openPrivateFile(context: Context, file: File) { private fun openPrivateFile(context: Context, file: File) {
try { try {
trackFileAccess(file.name) trackFileAccess(file.name)
// 💡 파일을 열고나서 즉시 접근 횟수 및 뷰 반영을 위해 다시 정렬/로드합니다.
loadFiles() loadFiles()
val uri = FileProvider.getUriForFile(context, "bums.lunatic.launcher.fileprovider", file)
val uri: Uri = FileProvider.getUriForFile(context, "bums.lunatic.launcher.fileprovider", file)
val mimeType = getMimeType(file)
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType) setDataAndType(uri, getMimeType(file))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
context.startActivity(intent) context.startActivity(intent)
} catch (e: IllegalArgumentException) {
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()
} }
@ -355,12 +635,9 @@ class CompletedFilesFragment : Fragment() {
private fun updateRecyclerViewLayoutManager() { private fun updateRecyclerViewLayoutManager() {
recyclerView.layoutManager = when (currentViewMode) { recyclerView.layoutManager = when (currentViewMode) {
FileViewMode.LIST_TEXT, FileViewMode.LIST_THUMB -> FileViewMode.LIST_TEXT, FileViewMode.LIST_THUMB -> LinearLayoutManager(requireContext())
LinearLayoutManager(requireContext()) FileViewMode.GRID_LARGE -> GridLayoutManager(requireContext(), 2)
FileViewMode.GRID_LARGE -> FileViewMode.GRID_SMALL -> GridLayoutManager(requireContext(), 4)
GridLayoutManager(requireContext(), 2)
FileViewMode.GRID_SMALL ->
GridLayoutManager(requireContext(), 4)
} }
} }
} }
@ -369,12 +646,15 @@ class CompletedFilesFragment : Fragment() {
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 // 💡 접근 빈도를 가져오기 위한 람다 함수 추가 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()
private var viewMode: FileViewMode = FileViewMode.LIST_THUMB private var viewMode: FileViewMode = FileViewMode.LIST_THUMB
// 💡 어댑터 내부에 선택된 파일 정보 저장
private var selectedFiles: Set<File> = emptySet()
fun submitList(files: List<File>) { fun submitList(files: List<File>) {
fileList = files fileList = files
notifyDataSetChanged() notifyDataSetChanged()
@ -385,25 +665,28 @@ class CompletedFilesAdapter(
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun getItemViewType(position: Int): Int { // 💡 선택 상태가 바뀔 때 UI 갱신을 위해 호출
return viewMode.ordinal fun updateSelection(selection: Set<File>) {
selectedFiles = selection
notifyDataSetChanged()
} }
override fun getItemViewType(position: Int) = viewMode.ordinal
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder {
val layoutRes = when (FileViewMode.values()[viewType]) { val layoutRes = when (FileViewMode.values()[viewType]) {
FileViewMode.LIST_TEXT -> R.layout.item_file_list_text FileViewMode.LIST_TEXT -> R.layout.item_file_list_text
FileViewMode.LIST_THUMB -> R.layout.item_file_list_thumb FileViewMode.LIST_THUMB -> R.layout.item_file_list_thumb
FileViewMode.GRID_LARGE, FileViewMode.GRID_SMALL -> R.layout.item_file_grid FileViewMode.GRID_LARGE, FileViewMode.GRID_SMALL -> R.layout.item_file_grid
} }
val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) return FileViewHolder(LayoutInflater.from(parent.context).inflate(layoutRes, parent, false))
return FileViewHolder(view)
} }
override fun onBindViewHolder(holder: FileViewHolder, position: Int) { override fun onBindViewHolder(holder: FileViewHolder, position: Int) {
holder.bind(fileList[position], viewMode) holder.bind(fileList[position], viewMode)
} }
override fun getItemCount(): Int = fileList.size override fun getItemCount() = fileList.size
inner class FileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { inner class FileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvFileName: TextView? = itemView.findViewById(R.id.tvFileName) private val tvFileName: TextView? = itemView.findViewById(R.id.tvFileName)
@ -411,10 +694,16 @@ class CompletedFilesAdapter(
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) {
// 💡 선택된 아이템이면 배경색을 푸른색 반투명 효과로 표시
if (selectedFiles.contains(file)) {
itemView.setBackgroundColor(Color.parseColor("#440099FF"))
} else {
itemView.setBackgroundColor(Color.TRANSPARENT)
}
if (file.name == "..") { if (file.name == "..") {
tvFileName?.text = "📁 .. (상위 폴더로)" tvFileName?.text = "📁 .. (상위 폴더로)"
tvFileSize?.text = "이전 경로" tvFileSize?.text = "이전 경로"
if (ivThumb != null) { if (ivThumb != null) {
Glide.with(itemView.context).clear(ivThumb) Glide.with(itemView.context).clear(ivThumb)
ivThumb.setImageResource(android.R.drawable.ic_menu_revert) ivThumb.setImageResource(android.R.drawable.ic_menu_revert)
@ -423,7 +712,6 @@ class CompletedFilesAdapter(
tvFileName?.text = "📁 ${file.name}" tvFileName?.text = "📁 ${file.name}"
val count = file.listFiles()?.size ?: 0 val count = file.listFiles()?.size ?: 0
tvFileSize?.text = "항목 $count" tvFileSize?.text = "항목 $count"
if (ivThumb != null) { if (ivThumb != null) {
Glide.with(itemView.context).clear(ivThumb) Glide.with(itemView.context).clear(ivThumb)
ivThumb.setImageResource(android.R.drawable.ic_menu_gallery) ivThumb.setImageResource(android.R.drawable.ic_menu_gallery)
@ -431,18 +719,11 @@ class CompletedFilesAdapter(
} else { } else {
tvFileName?.text = file.name tvFileName?.text = file.name
val sizeMb = file.length() / (1024.0 * 1024.0) val sizeMb = file.length() / (1024.0 * 1024.0)
// 💡 접근 빈도를 가져와 텍스트에 포함시킵니다.
val accessCount = getAccessCount(file.name) val accessCount = getAccessCount(file.name)
val countText = if (accessCount > 0) "조회: ${accessCount}" else "새 파일" val countText = if (accessCount > 0) "조회: ${accessCount}" else "새 파일"
tvFileSize?.text = String.format("%.2f MB • %s • %s", sizeMb, file.extension.uppercase(), countText) tvFileSize?.text = String.format("%.2f MB • %s • %s", sizeMb, file.extension.uppercase(), countText)
if (ivThumb != null) { if (ivThumb != null) {
Glide.with(itemView.context) Glide.with(itemView.context).load(file).placeholder(android.R.drawable.ic_menu_report_image).into(ivThumb)
.load(file)
.placeholder(android.R.drawable.ic_menu_report_image)
.into(ivThumb)
} }
} }

View File

@ -68,6 +68,7 @@ import org.mozilla.geckoview.WebExtension
import org.mozilla.geckoview.WebExtension.MessageDelegate 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.WebResponse import org.mozilla.geckoview.WebResponse
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@ -343,11 +344,31 @@ open class GeckoWeb @JvmOverloads constructor(
// scrollState = 0 // scrollState = 0
} }
override fun onLoadError(
session: GeckoSession,
uri: String?,
error: WebRequestError
): GeckoResult<String?>? {
Blog.LOGE("Gecko 에러 발생!! URI: $uri, Code: ${error.code}, Category: ${error.category}")
if (uri?.contains("booktoki") == true) {
when(lastArrow) {
1->{sendJsonMsg("CLICK_NEXT_CHAPTER")}
-1->{sendJsonMsg("CLICK_PREV_CHAPTER")}
else -> {}
}
} else {
session.reload()
}
return null
}
override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
this@GeckoWeb.canGoBack = canGoBack this@GeckoWeb.canGoBack = canGoBack
} }
} }
var lastArrow = 0
fun goBack() { fun goBack() {
if (true == canGoBack) if (true == canGoBack)
session?.goBack() session?.goBack()
@ -708,7 +729,7 @@ open class GeckoWeb @JvmOverloads constructor(
private fun View.post(action: () -> Unit) = this.post(Runnable(action)) private fun View.post(action: () -> Unit) = this.post(Runnable(action))
fun onPause() { fun onPause() {
saveCurrentSessionState() saveCurrentSessionState()
session?.stop() // session?.stop()
session?.setActive(false) session?.setActive(false)
Blog.LOGE("called onstop $lastedUrl") Blog.LOGE("called onstop $lastedUrl")
} }

View File

@ -310,7 +310,8 @@ open class NeoRssActivity : CommonActivity() {
} }
override fun dispatchGenericMotionEvent(ev: MotionEvent?): Boolean { override fun dispatchGenericMotionEvent(ev: MotionEvent?): Boolean {
Blog.LOGE("keyEvent >>>>> dispatchGenericMotionEvent ${ev}") if ("Virtual".equals(ev?.device?.name)) return true
Blog.LOGE("keyEvent >>>>> dispatchGenericMotionEvent ${ev?.device?.name ?: ""}: ${ev} ")
if (ev?.device?.name?.contains("BLE-M3") == true) { if (ev?.device?.name?.contains("BLE-M3") == true) {
ev?.action?.let { action -> ev?.action?.let { action ->
when(action) { when(action) {
@ -354,6 +355,8 @@ open class NeoRssActivity : CommonActivity() {
Blog.LOGE("Arrow Up Click") Blog.LOGE("Arrow Up Click")
} }
} }
// if (onExit) return true // if (onExit) return true
return false return false
} }
@ -615,9 +618,9 @@ open class NeoRssActivity : CommonActivity() {
is TokiFragment -> { is TokiFragment -> {
currentFragment.back() currentFragment.back()
} }
// is Novels -> { is CompletedFilesFragment -> {
// currentFragment.actionNextEvent(false) currentFragment.backPress()
// } }
else -> { else -> {
// showContents(R.id.close) // showContents(R.id.close)
} }

View File

@ -414,6 +414,8 @@ object HistoryManager {
url = url.replace("//","/").trim() url = url.replace("//","/").trim()
} }
} }
Blog.LOGE("CURENTPATH $url")
var contentsPageInfo = var contentsPageInfo =
this.query(ContentsPageInfo::class).query("contentsType == $0", contentsType).query("pathUrl == $0", url).find() this.query(ContentsPageInfo::class).query("contentsType == $0", contentsType).query("pathUrl == $0", url).find()
if (contentsPageInfo.size > 0) { if (contentsPageInfo.size > 0) {

View File

@ -307,7 +307,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
} }
override fun onKeyEvent(event: KeyEvent): Boolean { override fun onKeyEvent(event: KeyEvent): Boolean {
if (!isbooktoki()) return false // 조건부 처리 // if (!isbooktoki()) return false // 조건부 처리
val isUp = event.action == MotionEvent.ACTION_UP val isUp = event.action == MotionEvent.ACTION_UP
return when (event.keyCode) { return when (event.keyCode) {
@ -393,16 +393,16 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
override fun onLongClick() { override fun onLongClick() {
if (!enableGestures) return if (!enableGestures) return
if (contentsType.contains("book") && // if (contentsType.contains("book") &&
currentPage?.bookTitle?.isNotEmpty() == true && // currentPage?.bookTitle?.isNotEmpty() == true &&
currentPage?.chapterTitle?.isNotEmpty() == true && // currentPage?.chapterTitle?.isNotEmpty() == true &&
binding.pagedLayer.text.isNotEmpty() == true && (binding.pagedLayer.text.length > 100)) { // binding.pagedLayer.text.isNotEmpty() == true && (binding.pagedLayer.text.length > 100)) {
currentPage?.bookTitle?.let { title -> // currentPage?.bookTitle?.let { title ->
currentPage?.contents?.let { contents -> // currentPage?.contents?.let { contents ->
saveTextToPrivateVault(title, "${this.currentChapter}_${this.currentTitle}",binding.pagedLayer.text) // saveTextToPrivateVault(title, "${this.currentChapter}_${this.currentTitle}",binding.pagedLayer.text)
} // }
} // }
} // }
Blog.LOGD(log = "onLongClick") Blog.LOGD(log = "onLongClick")
} }
@ -1045,6 +1045,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
if (pathUrl != null && pathUrl.length > 6) { if (pathUrl != null && pathUrl.length > 6) {
HistoryManager.getNextPage(contentsType,pathUrl) { HistoryManager.getNextPage(contentsType,pathUrl) {
if (it != null && (it.pathUrl?.length ?: 0) > 6) { if (it != null && (it.pathUrl?.length ?: 0) > 6) {
binding.lunaticBrowser.geckoWeb.lastArrow = 1
moveTo(it) moveTo(it)
} else { } else {
showToast("다음 편이 없다요.") showToast("다음 편이 없다요.")
@ -1057,6 +1058,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
if (pathUrl != null && pathUrl.length > 6) { if (pathUrl != null && pathUrl.length > 6) {
HistoryManager.getPrevPage(contentsType,pathUrl) { HistoryManager.getPrevPage(contentsType,pathUrl) {
if (it != null && (it.pathUrl?.length ?: 0) > 6) { if (it != null && (it.pathUrl?.length ?: 0) > 6) {
binding.lunaticBrowser.geckoWeb.lastArrow = -1
moveTo(it) moveTo(it)
} else { } else {
showToast("이전 편이 없다요.") showToast("이전 편이 없다요.")
@ -1099,12 +1101,46 @@ 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 (contentsType.contains("book")) {
binding.pagedLayer.doNext(fast) if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
binding.lunaticBrowser.geckoWeb.pageDown() binding.pagedLayer.doNext(fast)
updateLastInfo(binding.pagedLayer!!) binding.lunaticBrowser.geckoWeb.pageDown()
updateLastInfo(binding.pagedLayer)
} else {
binding.pagedLayer.visibility = View.GONE
binding.lunaticBrowser.visibility = View.VISIBLE
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
}
} else if (contentsType.contains("comics")) {
sendViewerTouch("right")
} else { } else {
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path) if (binding.lunaticBrowser.geckoWeb.scrollState > 0) {
} else {
binding.lunaticBrowser.geckoWeb.pageDown()
}
}
}
fun actionPrevEvent(fast: Boolean = false) {
if(contentsType.contains("book")) {
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && binding.pagedLayer.current() > 0) {
binding.pagedLayer.doPrev(fast)
binding.lunaticBrowser.geckoWeb.pageUp()
updateLastInfo(binding.pagedLayer)
} else {
binding.pagedLayer.visibility = View.GONE
binding.lunaticBrowser.visibility = View.VISIBLE
moveToPrev(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
}
} else if (contentsType.contains("comics")) {
sendViewerTouch("left")
} else {
if (binding.lunaticBrowser.geckoWeb.scrollState < 0) {
} else {
binding.lunaticBrowser.geckoWeb.pageUp()
}
} }
} }
@ -1135,16 +1171,6 @@ 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)
}
}
fun showAlert(alert: String) { fun showAlert(alert: String) {
Log.i(TAG, "showAlert >> " + alert) Log.i(TAG, "showAlert >> " + alert)
} }
@ -1239,6 +1265,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
view.visibility = if (!saveContinuation) VISIBLE else GONE view.visibility = if (!saveContinuation) VISIBLE else GONE
binding.lunaticBrowser.visibility = if (saveContinuation) VISIBLE else GONE binding.lunaticBrowser.visibility = if (saveContinuation) VISIBLE else GONE
} }
binding.lunaticBrowser.geckoWeb.lastArrow = 0
// view.forceUpdateUI() // view.forceUpdateUI()
lastedUrl?.let { lastedUrl?.let {
Uri.parse(it)?.let { uri -> Uri.parse(it)?.let { uri ->

View File

@ -152,7 +152,7 @@
style="@style/SearchAccs" style="@style/SearchAccs"
app:autoSizeTextType="uniform" app:autoSizeTextType="uniform"
android:id="@+id/search_music" android:id="@+id/search_music"
android:text="SPOTIFY" android:text="MUSIC"
/> />
<TextView <TextView
style="@style/SearchAccs" style="@style/SearchAccs"

View File

@ -75,6 +75,47 @@
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewCompletedFiles" android:id="@+id/recyclerViewCompletedFiles"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1"
android:padding="8dp"/> android:padding="8dp"/>
<LinearLayout
android:id="@+id/layoutSelectionActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="#EEEEEE"
android:padding="8dp"
android:visibility="gone">
<TextView
android:id="@+id/btnCancelSelection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="취소"
android:textStyle="bold"
android:gravity="center"
android:padding="12dp"/>
<TextView
android:id="@+id/btnMoveSelected"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="폴더 이동"
android:textStyle="bold"
android:gravity="center"
android:padding="12dp"/>
<TextView
android:id="@+id/btnDeleteSelected"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="선택 삭제"
android:textColor="#FF0000"
android:textStyle="bold"
android:gravity="center"
android:padding="12dp"/>
</LinearLayout>
</LinearLayout> </LinearLayout>