...
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.BLUETOOTH" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.PACKAGE_USAGE_STATS"
|
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|||||||
@ -51,6 +51,10 @@ import bums.lunatic.launcher.common.CommonActivity
|
|||||||
import bums.lunatic.launcher.databinding.LauncherActivityBinding
|
import bums.lunatic.launcher.databinding.LauncherActivityBinding
|
||||||
import bums.lunatic.launcher.feeds.WidgetHost
|
import bums.lunatic.launcher.feeds.WidgetHost
|
||||||
import bums.lunatic.launcher.helpers.ForeGroundService
|
import bums.lunatic.launcher.helpers.ForeGroundService
|
||||||
|
import bums.lunatic.launcher.helpers.ForeGroundService.Companion.ACTION_VIDEO_DOWNLOAD
|
||||||
|
import bums.lunatic.launcher.helpers.ForeGroundService.Companion.EXTRA_MSGKEY
|
||||||
|
import bums.lunatic.launcher.helpers.ForeGroundService.Companion.EXTRA_PROGRESS
|
||||||
|
import bums.lunatic.launcher.helpers.ForeGroundService.Companion.EXTRA_TARGET_URL
|
||||||
import bums.lunatic.launcher.helpers.HeadsetActionButtonReceiver
|
import bums.lunatic.launcher.helpers.HeadsetActionButtonReceiver
|
||||||
import bums.lunatic.launcher.home.GeckoWeb
|
import bums.lunatic.launcher.home.GeckoWeb
|
||||||
import bums.lunatic.launcher.home.NeoRssActivity
|
import bums.lunatic.launcher.home.NeoRssActivity
|
||||||
@ -227,45 +231,73 @@ open class LauncherActivity : CommonActivity() {
|
|||||||
|
|
||||||
// 💡 URI로부터 파일을 읽어서 프라이빗 폴더로 복사하는 함수
|
// 💡 URI로부터 파일을 읽어서 프라이빗 폴더로 복사하는 함수
|
||||||
private fun saveToPrivateVault(uri: Uri) {
|
private fun saveToPrivateVault(uri: Uri) {
|
||||||
|
var fileName = "shared_${System.currentTimeMillis()}"
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
// 1. 원본 파일명 알아내기
|
// 1. 원본 파일명 및 총 파일 크기(용량) 알아내기
|
||||||
var fileName = "shared_${System.currentTimeMillis()}"
|
var totalFileSize = 0L // 💡 파일 크기를 담을 변수 추가
|
||||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
if (nameIndex != -1) {
|
if (nameIndex != -1) {
|
||||||
fileName = cursor.getString(nameIndex)
|
fileName = cursor.getString(nameIndex)
|
||||||
}
|
}
|
||||||
|
// 💡 SIZE 컬럼을 통해 파일의 전체 바이트 크기를 가져옵니다.
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex != -1) {
|
||||||
|
totalFileSize = cursor.getLong(sizeIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val vaultDir = File(getExternalFilesDir(null), "completed_torrents")
|
val vaultDir = File(getExternalFilesDir(null), "completed_torrents")
|
||||||
if (!vaultDir.exists()) vaultDir.mkdirs()
|
if (!vaultDir.exists()) vaultDir.mkdirs()
|
||||||
|
|
||||||
// 2. 이름 중복 덮어쓰기 방지를 위해 타임스탬프 추가
|
|
||||||
val destFile = File(vaultDir, "${System.currentTimeMillis()}_$fileName")
|
val destFile = File(vaultDir, "${System.currentTimeMillis()}_$fileName")
|
||||||
|
notiState("${destFile.name} 복사 시작",0)
|
||||||
// 3. 스트림 복사 (외부 파일 -> 내 프라이빗 폴더)
|
// 3. 수동 스트림 복사 및 진행률 계산
|
||||||
contentResolver.openInputStream(uri)?.use { input ->
|
contentResolver.openInputStream(uri)?.use { input ->
|
||||||
destFile.outputStream().use { output ->
|
destFile.outputStream().use { output ->
|
||||||
input.copyTo(output)
|
val buffer = ByteArray(8 * 1024) // 8KB씩 쪼개서 복사
|
||||||
|
var bytesRead: Int
|
||||||
|
var copiedSize = 0L
|
||||||
|
var lastReportedProgress = -1
|
||||||
|
|
||||||
|
// 💡 파일 끝에 도달할 때까지 8KB씩 계속 읽고 씁니다.
|
||||||
|
while (input.read(buffer).also { bytesRead = it } >= 0) {
|
||||||
|
output.write(buffer, 0, bytesRead)
|
||||||
|
copiedSize += bytesRead
|
||||||
|
|
||||||
|
// 진행률 계산 로직 (총 크기를 아는 경우에만)
|
||||||
|
if (totalFileSize > 0) {
|
||||||
|
val progress = ((copiedSize.toDouble() / totalFileSize.toDouble()) * 100).toInt()
|
||||||
|
|
||||||
|
// 1% 단위로 변경될 때만 알림을 갱신합니다 (시스템 과부하 방지)
|
||||||
|
if (progress != lastReportedProgress) {
|
||||||
|
lastReportedProgress = progress
|
||||||
|
notiState("${destFile.name} ${progress}% 복사 중",progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notiState("${destFile.name} ${100}% 복사 완료",100)
|
||||||
// 4. 완료 후 UI 스레드에서 토스트 띄우기
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
showToast("보관함에 저장되었습니다: $fileName")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
withContext(Dispatchers.Main) {
|
notiState("${fileName} 복사 실패 : ${e.message}",0)
|
||||||
showToast("파일을 보관함으로 복사하지 못했습니다.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun notiState(msg : String, progress : Int) {
|
||||||
|
val intent = Intent(this@LauncherActivity, ForeGroundService::class.java).apply {
|
||||||
|
action = ForeGroundService.ACTION_COPY_COMPLETE
|
||||||
|
putExtra(EXTRA_MSGKEY, msg)
|
||||||
|
putExtra(EXTRA_PROGRESS, progress)
|
||||||
|
}
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
fun onSwipeLeft() {
|
fun onSwipeLeft() {
|
||||||
showAppDrawer()
|
showAppDrawer()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,10 @@ import java.util.concurrent.TimeUnit
|
|||||||
import android.bluetooth.BluetoothClass
|
import android.bluetooth.BluetoothClass
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import androidx.annotation.RequiresPermission
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
class AggregatedSystemWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
class AggregatedSystemWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
@ -85,10 +88,13 @@ class ForeGroundService : Service() {
|
|||||||
companion object {
|
companion object {
|
||||||
val ACTION_SENDMSG = "ACTION_SEND_TO_LOVE"
|
val ACTION_SENDMSG = "ACTION_SEND_TO_LOVE"
|
||||||
val EXTRA_MSGKEY = "SEND_MSG"
|
val EXTRA_MSGKEY = "SEND_MSG"
|
||||||
|
val EXTRA_PROGRESS = "EXTRA_PROGRESS"
|
||||||
|
|
||||||
val ACTION_VIDEO_DOWNLOAD = "ACTION_YTURL_DOWNLOAD"
|
val ACTION_VIDEO_DOWNLOAD = "ACTION_YTURL_DOWNLOAD"
|
||||||
val EXTRA_TARGET_URL = "ACTION_SEND_TO_LOVE"
|
val EXTRA_TARGET_URL = "ACTION_SEND_TO_LOVE"
|
||||||
val ACTION_SIT_DOWN = "ACTION_SIT_DOWN"
|
val ACTION_SIT_DOWN = "ACTION_SIT_DOWN"
|
||||||
|
val ACTION_COPY_COMPLETE = "ACTION_COPY_COMPLETE"
|
||||||
|
|
||||||
val targetUrls = arrayListOf<String>()
|
val targetUrls = arrayListOf<String>()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,7 +114,7 @@ class ForeGroundService : Service() {
|
|||||||
val filter = IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED)
|
val filter = IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED)
|
||||||
registerReceiver(bluetoothreceiver, filter)
|
registerReceiver(bluetoothreceiver, filter)
|
||||||
refreshFeeds()
|
refreshFeeds()
|
||||||
startForeGround()
|
startForeGround(vibrator = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@ -137,7 +143,10 @@ class ForeGroundService : Service() {
|
|||||||
sitDownRequest
|
sitDownRequest
|
||||||
)
|
)
|
||||||
// 사용자 확인을 위해 알림 텍스트 변경 가능
|
// 사용자 확인을 위해 알림 텍스트 변경 가능
|
||||||
startForeGround(0, 0, "45분 뒤에 진동 알림이 예약되었습니다.")
|
startForeGround(max = 0, progress = 0, str = "45분 뒤에 진동 알림이 예약되었습니다.")
|
||||||
|
}
|
||||||
|
ACTION_COPY_COMPLETE -> {
|
||||||
|
startForeGround(max = 100, progress = intent?.getIntExtra(EXTRA_PROGRESS,0) ?: 0, str = intent?.getStringExtra(EXTRA_MSGKEY))
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
|
||||||
@ -161,7 +170,7 @@ class ForeGroundService : Service() {
|
|||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
startForeGround(0,0)
|
startForeGround(max= 0, progress = 0, vibrator = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +190,7 @@ class ForeGroundService : Service() {
|
|||||||
currentProcessId = UUID.randomUUID().toString()
|
currentProcessId = UUID.randomUUID().toString()
|
||||||
YoutubeDL.getInstance()
|
YoutubeDL.getInstance()
|
||||||
.execute(command, currentProcessId) { progress, est, str ->
|
.execute(command, currentProcessId) { progress, est, str ->
|
||||||
startForeGround(100, progress.toInt(),str)
|
startForeGround(100, progress.toInt(),str, true)
|
||||||
if (progress >= 100) {
|
if (progress >= 100) {
|
||||||
targetUrls.remove(url)
|
targetUrls.remove(url)
|
||||||
currentProcessId = null
|
currentProcessId = null
|
||||||
@ -201,17 +210,40 @@ class ForeGroundService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startForeGround(max : Int = 0 , progress : Int = 0, str : String? = "실행중입니다.") {
|
fun startForeGround(max : Int = 0, progress : Int = 0, str : String? = "실행중입니다.", vibrator : Boolean? = false) {
|
||||||
Blog.LOGE(str?:"")
|
|
||||||
|
val currentChannelId = if (vibrator == true) "${CHANNEL_ID}_vibrate" else "${CHANNEL_ID}_silent"
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
currentChannelId,
|
||||||
"BUM'S 서비스",
|
"BUM'S 서비스",
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
)
|
).apply {
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
if (vibrator == true) {
|
||||||
|
enableVibration(true)
|
||||||
|
vibrationPattern = longArrayOf(0, 500) // 0초 대기 후 0.5초 진동
|
||||||
|
} else {
|
||||||
|
enableVibration(false)
|
||||||
|
setSound(null, null) // 소리도 확실하게 제거
|
||||||
|
}
|
||||||
|
}
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💡 2. 파라미터가 true일 때, 알림 채널과 별개로 진동 모터를 직접 한 번 강제로 울려줍니다.
|
||||||
|
// (화면이 켜져 있거나 시스템 설정에 의해 알림 진동이 무시되는 경우를 대비한 확실한 장치)
|
||||||
|
if (vibrator == true) {
|
||||||
|
val vibratorService = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
vibratorService.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
} else {
|
||||||
|
vibratorService.vibrate(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val intent = Intent(this, LauncherActivity::class.java).apply {
|
val intent = Intent(this, LauncherActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
@ -223,17 +255,24 @@ class ForeGroundService : Service() {
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
startForeground(NOTIF_ID, NotificationCompat.Builder(this, CHANNEL_ID)
|
// 💡 3. Builder에 currentChannelId 적용 및 하위 버전(Oreo 미만) 진동 호환성 추가
|
||||||
|
val builder = NotificationCompat.Builder(this, currentChannelId)
|
||||||
.setContentTitle("BUM'S 서비스")
|
.setContentTitle("BUM'S 서비스")
|
||||||
.setContentText(str)
|
.setContentText(str)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
.setSmallIcon(R.drawable.ic_b)
|
.setSmallIcon(R.drawable.ic_b)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.addAction(android.R.drawable.ic_btn_speak_now,"퇴근", makeSendMsgAction(0,"돼지 퇴근했다요~!"))
|
.addAction(android.R.drawable.ic_btn_speak_now, "퇴근", makeSendMsgAction(0, "돼지 퇴근했다요~!"))
|
||||||
.addAction(android.R.drawable.ic_menu_directions, "앉음", makeSitDownAction())
|
.addAction(android.R.drawable.ic_menu_directions, "앉음", makeSitDownAction())
|
||||||
.setOngoing(true) // 사용자가 알림을 스와이프로 지울 수 없게 만듦
|
.setOngoing(true)
|
||||||
.setProgress(max, progress, false)
|
.setProgress(max, progress, false)
|
||||||
.build())
|
|
||||||
|
// 안드로이드 8.0 미만 기기를 위한 진동 처리
|
||||||
|
if (vibrator == true) {
|
||||||
|
builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
startForeground(NOTIF_ID, builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun makeSitDownAction(): PendingIntent {
|
fun makeSitDownAction(): PendingIntent {
|
||||||
|
|||||||
@ -15,51 +15,45 @@ import android.widget.ImageView
|
|||||||
import android.widget.Spinner
|
import android.widget.Spinner
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import bums.lunatic.launcher.R
|
import bums.lunatic.launcher.R
|
||||||
import bums.lunatic.launcher.utils.Blog
|
import bums.lunatic.launcher.utils.NaturalOrderComparator
|
||||||
import bums.lunatic.launcher.utils.CommonUtils.getFileTypeBySignature
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
|
||||||
|
|
||||||
// 필터 및 정렬 관리를 위한 Enum
|
|
||||||
//"전체", "이미지", "영상", "문서", "기타"
|
|
||||||
enum class FileFilterType(val label : String) { ALL("전체"), IMAGE("이미지"), VIDEO("영상"), DOCUMENT("문서"), OTHER("기타") }
|
enum class FileFilterType(val label : String) { ALL("전체"), IMAGE("이미지"), VIDEO("영상"), DOCUMENT("문서"), OTHER("기타") }
|
||||||
enum class FileSortType(val label : String) { DOWNLOAD_DATE("다운로드일"), LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), SIZE("용량") }
|
enum class FileSortType(val label : String) { NAME("파일명") , DOWNLOAD_DATE("다운로드"), LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), SIZE("용량") }
|
||||||
enum class FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
|
enum class FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
|
||||||
|
|
||||||
//val sortOptions = arrayOf("다운로드일", "최근 사용", "자주 사용", "용량")
|
|
||||||
|
|
||||||
class CompletedFilesFragment : Fragment() {
|
class CompletedFilesFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var recyclerView: RecyclerView
|
private lateinit var recyclerView: RecyclerView
|
||||||
private lateinit var adapter: CompletedFilesAdapter
|
private lateinit var adapter: CompletedFilesAdapter
|
||||||
|
|
||||||
// 원본 파일 목록
|
private lateinit var rootDir: File
|
||||||
|
private lateinit var currentDir: File
|
||||||
private var allFiles = listOf<File>()
|
private var allFiles = listOf<File>()
|
||||||
|
|
||||||
// 현재 선택된 상태값
|
|
||||||
private var currentFilter = FileFilterType.ALL
|
private var currentFilter = FileFilterType.ALL
|
||||||
private var currentSort = FileSortType.DOWNLOAD_DATE
|
private var currentSort = FileSortType.NAME
|
||||||
private var currentViewMode = FileViewMode.LIST_TEXT // 기본값: 썸네일 리스트
|
private var currentViewMode = FileViewMode.LIST_TEXT
|
||||||
private var isDescending = true // 기본: 내림차순 (최신순/큰용량순)
|
private var isDescending = true
|
||||||
|
|
||||||
// 파일 확장자 분류 기준
|
|
||||||
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
|
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
|
||||||
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
|
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
|
||||||
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp")
|
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp")
|
||||||
|
|
||||||
|
private lateinit var backPressedCallback: OnBackPressedCallback
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
@ -70,6 +64,17 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
rootDir = File(requireContext().getExternalFilesDir(null), "completed_torrents")
|
||||||
|
if (!rootDir.exists()) rootDir.mkdirs()
|
||||||
|
currentDir = rootDir
|
||||||
|
|
||||||
|
backPressedCallback = object : OnBackPressedCallback(false) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback)
|
||||||
|
|
||||||
setupRecyclerView(view)
|
setupRecyclerView(view)
|
||||||
setupControls(view)
|
setupControls(view)
|
||||||
loadFiles()
|
loadFiles()
|
||||||
@ -77,20 +82,30 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
|
|
||||||
private fun setupRecyclerView(view: View) {
|
private fun setupRecyclerView(view: View) {
|
||||||
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
|
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
|
||||||
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
updateRecyclerViewLayoutManager()
|
||||||
|
|
||||||
|
// 💡 어댑터 생성 시 getAccessCount 함수를 넘겨주어 어댑터 내부에서 조회수를 그릴 수 있게 합니다.
|
||||||
adapter = CompletedFilesAdapter(
|
adapter = CompletedFilesAdapter(
|
||||||
onItemClick = { file ->
|
onItemClick = { file ->
|
||||||
openPrivateFile(requireContext(), file)
|
if (file.name == "..") {
|
||||||
|
navigateUp()
|
||||||
|
} else if (file.isDirectory) {
|
||||||
|
currentDir = file
|
||||||
|
loadFiles()
|
||||||
|
} else {
|
||||||
|
openPrivateFile(requireContext(), file)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onItemLongClick = { file ->
|
onItemLongClick = { file ->
|
||||||
|
val type = if (file.isDirectory) "폴더" else "파일"
|
||||||
android.app.AlertDialog.Builder(context)
|
android.app.AlertDialog.Builder(context)
|
||||||
.setTitle("파일 삭제")
|
.setTitle("$type 삭제")
|
||||||
.setMessage("해당 파일을 삭제하시겠습니까?")
|
.setMessage("해당 ${type}를 삭제하시겠습니까?\n(폴더일 경우 내부 파일도 모두 삭제됩니다)")
|
||||||
.setPositiveButton("확인") { _, _ ->
|
.setPositiveButton("확인") { _, _ ->
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
if (file.delete()) {
|
val success = if (file.isDirectory) file.deleteRecursively() else file.delete()
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
if (success) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(requireContext(), "삭제되었습니다.", Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), "삭제되었습니다.", Toast.LENGTH_SHORT).show()
|
||||||
loadFiles()
|
loadFiles()
|
||||||
}
|
}
|
||||||
@ -99,18 +114,26 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
.setNegativeButton("취소", null)
|
.setNegativeButton("취소", null)
|
||||||
.show()
|
.show()
|
||||||
|
},
|
||||||
}
|
getAccessCount = { fileName -> getAccessCount(fileName) }
|
||||||
)
|
)
|
||||||
|
adapter.setViewMode(currentViewMode)
|
||||||
recyclerView.adapter = adapter
|
recyclerView.adapter = adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun navigateUp() {
|
||||||
|
if (currentDir.absolutePath != rootDir.absolutePath) {
|
||||||
|
currentDir = currentDir.parentFile ?: rootDir
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getViewModeSymbolName(mode: FileViewMode): String {
|
private fun getViewModeSymbolName(mode: FileViewMode): String {
|
||||||
return when (mode) {
|
return when (mode) {
|
||||||
FileViewMode.LIST_TEXT -> "view_headline" // 텍스트 리스트
|
FileViewMode.LIST_TEXT -> "view_headline"
|
||||||
FileViewMode.LIST_THUMB -> "view_list" // 썸네일 리스트
|
FileViewMode.LIST_THUMB -> "view_list"
|
||||||
FileViewMode.GRID_LARGE -> "grid_view" // 큰 그리드
|
FileViewMode.GRID_LARGE -> "grid_view"
|
||||||
FileViewMode.GRID_SMALL -> "apps" // 작은 그리드 (view_comfy 도 가능)
|
FileViewMode.GRID_SMALL -> "apps"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,12 +141,15 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
val spinnerFilter = view.findViewById<Spinner>(R.id.spinnerFilter)
|
val spinnerFilter = view.findViewById<Spinner>(R.id.spinnerFilter)
|
||||||
val spinnerSort = view.findViewById<Spinner>(R.id.spinnerSort)
|
val spinnerSort = view.findViewById<Spinner>(R.id.spinnerSort)
|
||||||
val tvSortOrder = view.findViewById<TextView>(R.id.tvSortOrder)
|
val tvSortOrder = view.findViewById<TextView>(R.id.tvSortOrder)
|
||||||
|
|
||||||
val btnViewMode = view.findViewById<TextView>(R.id.btnViewMode)
|
val btnViewMode = view.findViewById<TextView>(R.id.btnViewMode)
|
||||||
|
|
||||||
// 초기 뷰 모드에 맞는 이모지 세팅
|
// 💡 XML에 추가된 자동 정리 버튼 연동 (ID를 btnOrganize로 만들었다고 가정)
|
||||||
|
val btnOrganize = view.findViewById<TextView>(R.id.btnOrganize)
|
||||||
|
btnOrganize?.setOnClickListener {
|
||||||
|
organizeRootFiles()
|
||||||
|
}
|
||||||
|
|
||||||
btnViewMode.text = getViewModeSymbolName(currentViewMode)
|
btnViewMode.text = getViewModeSymbolName(currentViewMode)
|
||||||
adapter.setViewMode(currentViewMode)
|
|
||||||
btnViewMode.setOnClickListener {
|
btnViewMode.setOnClickListener {
|
||||||
currentViewMode = when (currentViewMode) {
|
currentViewMode = when (currentViewMode) {
|
||||||
FileViewMode.LIST_TEXT -> FileViewMode.LIST_THUMB
|
FileViewMode.LIST_TEXT -> FileViewMode.LIST_THUMB
|
||||||
@ -133,13 +159,14 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
btnViewMode.text = getViewModeSymbolName(currentViewMode)
|
btnViewMode.text = getViewModeSymbolName(currentViewMode)
|
||||||
updateRecyclerViewLayoutManager()
|
updateRecyclerViewLayoutManager()
|
||||||
// 모드 변경을 어댑터에 알림
|
|
||||||
adapter.setViewMode(currentViewMode)
|
adapter.setViewMode(currentViewMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 필터 스피너 설정
|
|
||||||
val filterOptions = FileFilterType.values().map { it.label }
|
val filterOptions = FileFilterType.values().map { it.label }
|
||||||
spinnerFilter.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions)
|
spinnerFilter.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions).apply {
|
||||||
|
setDropDownViewResource(R.layout.spinner_item_dark)
|
||||||
|
}
|
||||||
|
|
||||||
spinnerFilter.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
spinnerFilter.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
|
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
|
||||||
currentFilter = FileFilterType.values()[position]
|
currentFilter = FileFilterType.values()[position]
|
||||||
@ -148,9 +175,11 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
override fun onNothingSelected(p0: AdapterView<*>?) {}
|
override fun onNothingSelected(p0: AdapterView<*>?) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 정렬 스피너 설정
|
|
||||||
val sortOptions = FileSortType.values().map { it.label }
|
val sortOptions = FileSortType.values().map { it.label }
|
||||||
spinnerSort.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, sortOptions)
|
spinnerSort.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, sortOptions).apply {
|
||||||
|
setDropDownViewResource(R.layout.spinner_item_dark)
|
||||||
|
}
|
||||||
spinnerSort.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
spinnerSort.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
|
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
|
||||||
currentSort = FileSortType.values()[position]
|
currentSort = FileSortType.values()[position]
|
||||||
@ -159,36 +188,78 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
override fun onNothingSelected(p0: AdapterView<*>?) {}
|
override fun onNothingSelected(p0: AdapterView<*>?) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 정순/역순 토글 버튼 설정
|
|
||||||
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
|
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
|
||||||
tvSortOrder.setOnClickListener {
|
tvSortOrder.setOnClickListener {
|
||||||
isDescending = !isDescending
|
isDescending = !isDescending
|
||||||
// 내림차순이면 아래 화살표, 오름차순이면 위 화살표
|
|
||||||
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
|
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
|
||||||
applyFilterAndSort()
|
applyFilterAndSort()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 목록 불러오기
|
// 💡 루트 경로에 있는 파일들을 각각의 폴더로 이동시키는 자동 정리 함수
|
||||||
|
private fun organizeRootFiles() {
|
||||||
|
val filesInRoot = rootDir.listFiles()?.filter { it.isFile } ?: emptyList()
|
||||||
|
|
||||||
|
if (filesInRoot.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), "루트 폴더에 정리할 파일이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var movedCount = 0
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
filesInRoot.forEach { file ->
|
||||||
|
val ext = file.extension.lowercase()
|
||||||
|
val folderName = when {
|
||||||
|
extImages.contains(ext) -> "이미지"
|
||||||
|
extVideos.contains(ext) -> "비디오"
|
||||||
|
extDocs.contains(ext) -> "문서"
|
||||||
|
else -> "기타"
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetDir = File(rootDir, folderName)
|
||||||
|
if (!targetDir.exists()) targetDir.mkdirs()
|
||||||
|
|
||||||
|
val targetFile = File(targetDir, file.name)
|
||||||
|
// 동일한 이름의 파일이 이미 있다면 덮어쓰거나 이동 실패할 수 있으므로 주의 (필요시 중복 네이밍 처리)
|
||||||
|
if (file.renameTo(targetFile)) {
|
||||||
|
movedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (movedCount > 0) {
|
||||||
|
Toast.makeText(requireContext(), "${movedCount}개의 파일을 자동 정리했습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
// 만약 사용자가 지금 루트 폴더를 보고 있다면 리스트를 즉시 갱신
|
||||||
|
if (currentDir.absolutePath == rootDir.absolutePath) {
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), "파일 정리에 실패했습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadFiles() {
|
private fun loadFiles() {
|
||||||
val completedDir = File(requireContext().getExternalFilesDir(null), "completed_torrents")
|
if (currentDir.exists()) {
|
||||||
if (completedDir.exists()) {
|
allFiles = currentDir.listFiles()?.toList() ?: emptyList()
|
||||||
allFiles = completedDir.walkTopDown().filter { it.isFile }.toList()
|
|
||||||
} else {
|
} else {
|
||||||
allFiles = emptyList()
|
allFiles = emptyList()
|
||||||
}
|
}
|
||||||
|
backPressedCallback.isEnabled = currentDir.absolutePath != rootDir.absolutePath
|
||||||
applyFilterAndSort()
|
applyFilterAndSort()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onHiddenChanged(hidden: Boolean) {
|
override fun onHiddenChanged(hidden: Boolean) {
|
||||||
super.onHiddenChanged(hidden)
|
super.onHiddenChanged(hidden)
|
||||||
loadFiles()
|
if (!hidden) loadFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💡 핵심 로직: 필터 및 정렬 적용
|
|
||||||
private fun applyFilterAndSort() {
|
private fun applyFilterAndSort() {
|
||||||
// 1. 필터링
|
val folders = allFiles.filter { it.isDirectory }
|
||||||
var processedList = allFiles.filter { file ->
|
var files = allFiles.filter { it.isFile }
|
||||||
|
|
||||||
|
files = files.filter { file ->
|
||||||
val ext = file.extension.lowercase()
|
val ext = file.extension.lowercase()
|
||||||
when (currentFilter) {
|
when (currentFilter) {
|
||||||
FileFilterType.ALL -> true
|
FileFilterType.ALL -> true
|
||||||
@ -198,86 +269,84 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
FileFilterType.OTHER -> !extImages.contains(ext) && !extVideos.contains(ext) && !extDocs.contains(ext)
|
FileFilterType.OTHER -> !extImages.contains(ext) && !extVideos.contains(ext) && !extDocs.contains(ext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val naturalComparator = NaturalOrderComparator()
|
||||||
// 2. 정렬
|
val sortedFolders = folders.sortedBy { it.name.lowercase() }
|
||||||
processedList = when (currentSort) {
|
val sortedFiles = when (currentSort) {
|
||||||
FileSortType.DOWNLOAD_DATE -> {
|
FileSortType.DOWNLOAD_DATE -> {
|
||||||
if (isDescending) processedList.sortedByDescending { it.lastModified() }
|
if (isDescending) files.sortedByDescending { it.lastModified() }
|
||||||
else processedList.sortedBy { it.lastModified() }
|
else files.sortedBy { it.lastModified() }
|
||||||
}
|
}
|
||||||
FileSortType.LAST_USED -> {
|
FileSortType.LAST_USED -> {
|
||||||
if (isDescending) processedList.sortedByDescending { getLastAccessedTime(it.name) }
|
if (isDescending) files.sortedByDescending { getLastAccessedTime(it.name) }
|
||||||
else processedList.sortedBy { getLastAccessedTime(it.name) }
|
else files.sortedBy { getLastAccessedTime(it.name) }
|
||||||
}
|
}
|
||||||
FileSortType.SIZE -> {
|
FileSortType.SIZE -> {
|
||||||
if (isDescending) processedList.sortedByDescending { it.length() }
|
if (isDescending) files.sortedByDescending { it.length() }
|
||||||
else processedList.sortedBy { it.length() }
|
else files.sortedBy { it.length() }
|
||||||
}
|
}
|
||||||
FileSortType.FREQUENTLY_USED -> {
|
FileSortType.FREQUENTLY_USED -> {
|
||||||
if (isDescending) processedList.sortedByDescending { getAccessCount(it.name) }
|
if (isDescending) files.sortedByDescending { getAccessCount(it.name) }
|
||||||
else processedList.sortedBy { getAccessCount(it.name) }
|
else files.sortedBy { getAccessCount(it.name) }
|
||||||
|
}
|
||||||
|
FileSortType.NAME -> {
|
||||||
|
if (isDescending) files.sortedWith(naturalComparator.reversed())
|
||||||
|
else files.sortedWith(naturalComparator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adapter.submitList(processedList)
|
val finalItems = mutableListOf<File>()
|
||||||
|
|
||||||
|
if (currentDir.absolutePath != rootDir.absolutePath) {
|
||||||
|
finalItems.add(File(currentDir, ".."))
|
||||||
|
}
|
||||||
|
|
||||||
|
finalItems.addAll(sortedFolders)
|
||||||
|
finalItems.addAll(sortedFiles)
|
||||||
|
|
||||||
|
adapter.submitList(finalItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun trackFileAccess(fileName: String) {
|
private fun trackFileAccess(fileName: String) {
|
||||||
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// 기존 횟수를 불러와서 1을 더함 (처음 열면 0 + 1)
|
|
||||||
val currentCount = prefs.getInt("${fileName}_count", 0)
|
val currentCount = prefs.getInt("${fileName}_count", 0)
|
||||||
|
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putLong(fileName, System.currentTimeMillis()) // 기존 시간 기록 유지
|
.putLong(fileName, System.currentTimeMillis())
|
||||||
.putInt("${fileName}_count", currentCount + 1) // 횟수 기록 추가
|
.putInt("${fileName}_count", currentCount + 1)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// '최근 사용 시간' 불러오기 (기존 함수 유지)
|
|
||||||
private fun getLastAccessedTime(fileName: String): Long {
|
private fun getLastAccessedTime(fileName: String): Long {
|
||||||
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
||||||
return prefs.getLong(fileName, 0L)
|
return prefs.getLong(fileName, 0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💡 '접근 빈도(횟수)' 불러오기 (신규)
|
|
||||||
private fun getAccessCount(fileName: String): Int {
|
private fun getAccessCount(fileName: String): Int {
|
||||||
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
||||||
return prefs.getInt("${fileName}_count", 0)
|
return prefs.getInt("${fileName}_count", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💡 확장자에 맞춰 동적 MIME 타입 생성
|
|
||||||
private fun getMimeType(file: File): String {
|
private fun getMimeType(file: File): String {
|
||||||
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(file).toString()) ?: file.extension
|
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(file).toString()) ?: file.extension
|
||||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) ?: "*/*"
|
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) ?: "*/*"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 외부 플레이어/뷰어로 파일 열기
|
|
||||||
private fun openPrivateFile(context: Context, file: File) {
|
private fun openPrivateFile(context: Context, file: File) {
|
||||||
try {
|
try {
|
||||||
// 파일을 열었으므로 최근 사용 시간 갱신
|
|
||||||
trackFileAccess(file.name)
|
trackFileAccess(file.name)
|
||||||
|
|
||||||
val uri: Uri = FileProvider.getUriForFile(
|
// 💡 파일을 열고나서 즉시 접근 횟수 및 뷰 반영을 위해 다시 정렬/로드합니다.
|
||||||
context,
|
loadFiles()
|
||||||
"bums.lunatic.launcher.fileprovider",
|
|
||||||
file
|
|
||||||
)
|
|
||||||
|
|
||||||
|
val uri: Uri = FileProvider.getUriForFile(context, "bums.lunatic.launcher.fileprovider", file)
|
||||||
val mimeType = getMimeType(file)
|
val mimeType = getMimeType(file)
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
setDataAndType(uri, mimeType)
|
setDataAndType(uri, mimeType)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
|
|
||||||
// 열고 나서 '최근 사용' 순서가 갱신되어야 하므로 리스트 다시 정렬
|
|
||||||
if (currentSort == FileSortType.LAST_USED) {
|
|
||||||
applyFilterAndSort()
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
e.printStackTrace()
|
|
||||||
Toast.makeText(context, "파일을 공유할 수 없습니다.", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "파일을 공유할 수 없습니다.", Toast.LENGTH_SHORT).show()
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
Toast.makeText(context, "이 파일을 열 수 있는 앱이 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "이 파일을 열 수 있는 앱이 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
|
||||||
@ -289,18 +358,18 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
FileViewMode.LIST_TEXT, FileViewMode.LIST_THUMB ->
|
FileViewMode.LIST_TEXT, FileViewMode.LIST_THUMB ->
|
||||||
LinearLayoutManager(requireContext())
|
LinearLayoutManager(requireContext())
|
||||||
FileViewMode.GRID_LARGE ->
|
FileViewMode.GRID_LARGE ->
|
||||||
GridLayoutManager(requireContext(), 2) // 3열 그리드
|
GridLayoutManager(requireContext(), 2)
|
||||||
FileViewMode.GRID_SMALL ->
|
FileViewMode.GRID_SMALL ->
|
||||||
GridLayoutManager(requireContext(), 4) // 5열 그리드
|
GridLayoutManager(requireContext(), 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RecyclerView 어댑터 ---
|
// --- RecyclerView 어댑터 ---
|
||||||
class CompletedFilesAdapter(
|
class CompletedFilesAdapter(
|
||||||
private val onItemClick: (File) -> Unit,
|
private val onItemClick: (File) -> Unit,
|
||||||
private val onItemLongClick: (File) -> Unit
|
private val onItemLongClick: (File) -> Unit,
|
||||||
|
private val getAccessCount: (String) -> Int // 💡 접근 빈도를 가져오기 위한 람다 함수 추가
|
||||||
) : RecyclerView.Adapter<CompletedFilesAdapter.FileViewHolder>() {
|
) : RecyclerView.Adapter<CompletedFilesAdapter.FileViewHolder>() {
|
||||||
|
|
||||||
private var fileList: List<File> = emptyList()
|
private var fileList: List<File> = emptyList()
|
||||||
@ -313,10 +382,9 @@ class CompletedFilesAdapter(
|
|||||||
|
|
||||||
fun setViewMode(mode: FileViewMode) {
|
fun setViewMode(mode: FileViewMode) {
|
||||||
viewMode = mode
|
viewMode = mode
|
||||||
notifyDataSetChanged() // 모드가 바뀌면 전체 리스트를 다시 그립니다.
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💡 모드에 따라 ViewType을 다르게 반환
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
override fun getItemViewType(position: Int): Int {
|
||||||
return viewMode.ordinal
|
return viewMode.ordinal
|
||||||
}
|
}
|
||||||
@ -338,29 +406,49 @@ class CompletedFilesAdapter(
|
|||||||
override fun getItemCount(): Int = fileList.size
|
override fun getItemCount(): Int = fileList.size
|
||||||
|
|
||||||
inner class FileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
inner class FileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
// 레이아웃에 따라 특정 뷰가 null일 수 있으므로 nullable(?)로 선언
|
|
||||||
private val tvFileName: TextView? = itemView.findViewById(R.id.tvFileName)
|
private val tvFileName: TextView? = itemView.findViewById(R.id.tvFileName)
|
||||||
private val tvFileSize: TextView? = itemView.findViewById(R.id.tvFileSize)
|
private val tvFileSize: TextView? = itemView.findViewById(R.id.tvFileSize)
|
||||||
private val ivThumb: ImageView? = itemView.findViewById(R.id.ivThumb)
|
private val ivThumb: ImageView? = itemView.findViewById(R.id.ivThumb)
|
||||||
|
|
||||||
fun bind(file: File, mode: FileViewMode) {
|
fun bind(file: File, mode: FileViewMode) {
|
||||||
tvFileName?.text = file.name
|
if (file.name == "..") {
|
||||||
|
tvFileName?.text = "📁 .. (상위 폴더로)"
|
||||||
|
tvFileSize?.text = "이전 경로"
|
||||||
|
|
||||||
val sizeMb = file.length() / (1024.0 * 1024.0)
|
if (ivThumb != null) {
|
||||||
tvFileSize?.text = String.format("%.2f MB • %s", sizeMb, file.extension.uppercase())
|
Glide.with(itemView.context).clear(ivThumb)
|
||||||
|
ivThumb.setImageResource(android.R.drawable.ic_menu_revert)
|
||||||
|
}
|
||||||
|
} else if (file.isDirectory) {
|
||||||
|
tvFileName?.text = "📁 ${file.name}"
|
||||||
|
val count = file.listFiles()?.size ?: 0
|
||||||
|
tvFileSize?.text = "항목 $count 개"
|
||||||
|
|
||||||
// 💡 썸네일 뷰가 존재하는 모드일 경우 Glide로 이미지 로딩
|
if (ivThumb != null) {
|
||||||
if (ivThumb != null) {
|
Glide.with(itemView.context).clear(ivThumb)
|
||||||
// 이미지나 영상 파일인 경우 썸네일 표시, 아니면 기본 아이콘 등 처리 가능
|
ivThumb.setImageResource(android.R.drawable.ic_menu_gallery)
|
||||||
Glide.with(itemView.context)
|
}
|
||||||
.load(file)
|
} else {
|
||||||
.placeholder(android.R.drawable.ic_menu_report_image) // 로딩 중 기본 이미지
|
tvFileName?.text = file.name
|
||||||
.into(ivThumb)
|
val sizeMb = file.length() / (1024.0 * 1024.0)
|
||||||
|
|
||||||
|
// 💡 접근 빈도를 가져와 텍스트에 포함시킵니다.
|
||||||
|
val accessCount = getAccessCount(file.name)
|
||||||
|
val countText = if (accessCount > 0) "조회: ${accessCount}회" else "새 파일"
|
||||||
|
|
||||||
|
tvFileSize?.text = String.format("%.2f MB • %s • %s", sizeMb, file.extension.uppercase(), countText)
|
||||||
|
|
||||||
|
if (ivThumb != null) {
|
||||||
|
Glide.with(itemView.context)
|
||||||
|
.load(file)
|
||||||
|
.placeholder(android.R.drawable.ic_menu_report_image)
|
||||||
|
.into(ivThumb)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
itemView.setOnClickListener { onItemClick(file) }
|
itemView.setOnClickListener { onItemClick(file) }
|
||||||
itemView.setOnLongClickListener {
|
itemView.setOnLongClickListener {
|
||||||
onItemLongClick(file)
|
if (file.name != "..") onItemLongClick(file)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,11 @@ import io.realm.kotlin.Realm
|
|||||||
import io.realm.kotlin.UpdatePolicy
|
import io.realm.kotlin.UpdatePolicy
|
||||||
import io.realm.kotlin.ext.copyFromRealm
|
import io.realm.kotlin.ext.copyFromRealm
|
||||||
import io.realm.kotlin.ext.query
|
import io.realm.kotlin.ext.query
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.mozilla.gecko.util.ThreadUtils
|
import org.mozilla.gecko.util.ThreadUtils
|
||||||
@ -60,6 +65,7 @@ import org.mozilla.geckoview.WebExtension.MessageDelegate
|
|||||||
import org.mozilla.geckoview.WebExtension.PortDelegate
|
import org.mozilla.geckoview.WebExtension.PortDelegate
|
||||||
import org.mozilla.geckoview.WebExtensionController.AddonManagerDelegate
|
import org.mozilla.geckoview.WebExtensionController.AddonManagerDelegate
|
||||||
import org.mozilla.geckoview.WebRequestError
|
import org.mozilla.geckoview.WebRequestError
|
||||||
|
import java.io.File
|
||||||
import java.lang.System.currentTimeMillis
|
import java.lang.System.currentTimeMillis
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@ -72,7 +78,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
// --- Configuration Properties (Arguments에서 로드) ---
|
// --- Configuration Properties (Arguments에서 로드) ---
|
||||||
private lateinit var contentsType: String
|
private lateinit var contentsType: String
|
||||||
private var lastNumber: Int = 0
|
private var lastNumber: Int = 0
|
||||||
lateinit var webcontentsName: String
|
lateinit var webcontentsName: String
|
||||||
private lateinit var afterDot: String
|
private lateinit var afterDot: String
|
||||||
private var useNumberInUrl: Boolean = true
|
private var useNumberInUrl: Boolean = true
|
||||||
private var enableGestures: Boolean = true
|
private var enableGestures: Boolean = true
|
||||||
@ -102,7 +108,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
var canGoBack: Boolean? = null
|
var canGoBack: Boolean? = null
|
||||||
protected lateinit var binding: BooktokiBinding
|
protected lateinit var binding: BooktokiBinding
|
||||||
|
|
||||||
// var mPort: WebExtension.Port? = null
|
// var mPort: WebExtension.Port? = null
|
||||||
val mPortNam = "browser"
|
val mPortNam = "browser"
|
||||||
val extPath = "resource://android/assets/extensions/my_extension/"
|
val extPath = "resource://android/assets/extensions/my_extension/"
|
||||||
val extId = "messaging@booktoki468.com"
|
val extId = "messaging@booktoki468.com"
|
||||||
@ -351,8 +357,52 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
// Implementation typically empty or same across fragments
|
// Implementation typically empty or same across fragments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveTextToPrivateVault(folderName: String, chapterTitle: String, content: String) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
// 1. 최상위 보관함 폴더
|
||||||
|
val baseVaultDir = File(requireContext().getExternalFilesDir(null), "completed_torrents")
|
||||||
|
|
||||||
|
// 2. 요청받은 하위 폴더(folderName) 생성
|
||||||
|
val targetDir = File(baseVaultDir, folderName)
|
||||||
|
if (!targetDir.exists()) {
|
||||||
|
targetDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 파일명 생성 (파일 시스템에서 에러를 낼 수 있는 특수문자 \ / : * ? " < > | 자동 제거)
|
||||||
|
val safeTitle = chapterTitle.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||||
|
val fileName = if (safeTitle.endsWith(".txt", ignoreCase = true)) safeTitle else "$safeTitle.txt"
|
||||||
|
|
||||||
|
val destFile = File(targetDir, fileName)
|
||||||
|
|
||||||
|
// 4. 텍스트 파일 쓰기 (덮어쓰기)
|
||||||
|
destFile.writeText(content)
|
||||||
|
|
||||||
|
// 5. 완료 후 토스트 띄우기
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
showToast("[$folderName] 폴더에 저장됨: $fileName")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
showToast("텍스트를 저장하지 못했습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onLongClick() {
|
override fun onLongClick() {
|
||||||
if (!enableGestures) return
|
if (!enableGestures) return
|
||||||
|
if (contentsType.contains("book") &&
|
||||||
|
currentPage?.bookTitle?.isNotEmpty() == true &&
|
||||||
|
currentPage?.chapterTitle?.isNotEmpty() == true &&
|
||||||
|
binding.pagedLayer.text.isNotEmpty() == true && (binding.pagedLayer.text.length > 100)) {
|
||||||
|
currentPage?.bookTitle?.let { title ->
|
||||||
|
currentPage?.contents?.let { contents ->
|
||||||
|
saveTextToPrivateVault(title, "${this.currentChapter}_${this.currentTitle}",binding.pagedLayer.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Blog.LOGD(log = "onLongClick")
|
Blog.LOGD(log = "onLongClick")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,6 +529,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
|
|
||||||
override fun onHiddenChanged(hidden: Boolean) {
|
override fun onHiddenChanged(hidden: Boolean) {
|
||||||
super.onHiddenChanged(hidden)
|
super.onHiddenChanged(hidden)
|
||||||
|
saveContinuation = false
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
// 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지
|
// 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지
|
||||||
binding.lunaticBrowser.geckoWeb?.onPause()
|
binding.lunaticBrowser.geckoWeb?.onPause()
|
||||||
@ -497,7 +548,6 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
): View {
|
): View {
|
||||||
Blog.LOGD(log = "onCreate ${this::class.java.name} >> savedInstanceState ${savedInstanceState}")
|
Blog.LOGD(log = "onCreate ${this::class.java.name} >> savedInstanceState ${savedInstanceState}")
|
||||||
binding = BooktokiBinding.inflate(inflater)
|
binding = BooktokiBinding.inflate(inflater)
|
||||||
binding = BooktokiBinding.inflate(inflater)
|
|
||||||
|
|
||||||
// 💡 1. Toki용 레이아웃 설정
|
// 💡 1. Toki용 레이아웃 설정
|
||||||
binding.lunaticBrowser.setupForToki()
|
binding.lunaticBrowser.setupForToki()
|
||||||
@ -652,11 +702,11 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
it.decoViews.clear()
|
it.decoViews.clear()
|
||||||
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.tv_address))
|
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.tv_address))
|
||||||
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_back))
|
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_back))
|
||||||
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_reload))
|
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_reload))
|
||||||
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_dl_video))
|
it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_dl_video))
|
||||||
it.restoreSessionState()
|
it.restoreSessionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1051,6 +1101,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
fun actionNextEvent(fast: Boolean = false) {
|
fun actionNextEvent(fast: Boolean = false) {
|
||||||
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
|
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
|
||||||
binding.pagedLayer.doNext(fast)
|
binding.pagedLayer.doNext(fast)
|
||||||
|
binding.lunaticBrowser.geckoWeb.pageDown()
|
||||||
updateLastInfo(binding.pagedLayer!!)
|
updateLastInfo(binding.pagedLayer!!)
|
||||||
} else {
|
} else {
|
||||||
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
||||||
@ -1087,6 +1138,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
fun actionPrevEvent(fast: Boolean = false) {
|
fun actionPrevEvent(fast: Boolean = false) {
|
||||||
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && binding.pagedLayer.current() > 0) {
|
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && binding.pagedLayer.current() > 0) {
|
||||||
binding.pagedLayer.doPrev(fast)
|
binding.pagedLayer.doPrev(fast)
|
||||||
|
binding.lunaticBrowser.geckoWeb.pageUp()
|
||||||
updateLastInfo(binding.pagedLayer)
|
updateLastInfo(binding.pagedLayer)
|
||||||
} else {
|
} else {
|
||||||
moveToPrev(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
moveToPrev(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
||||||
@ -1143,11 +1195,23 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (saveContinuation) {
|
if (saveContinuation) {
|
||||||
binding.lunaticBrowser.geckoWeb.postDelayed({
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
moveToNext(
|
binding.pagedLayer.visibility = View.INVISIBLE
|
||||||
currentPage?.pathUrl ?: lastedUrl?.toUri()?.path
|
binding.lunaticBrowser.visibility = View.VISIBLE
|
||||||
)
|
for (i in 0..24) {
|
||||||
}, 10000)
|
if (binding.lunaticBrowser.geckoWeb.scrollState < 1) {
|
||||||
|
binding.lunaticBrowser.geckoWeb.pageDown()
|
||||||
|
}
|
||||||
|
delay(Random.nextLong(1500L, 3000L) + 1000L)
|
||||||
|
}
|
||||||
|
binding.lunaticBrowser.geckoWeb.postDelayed({
|
||||||
|
moveToNext(
|
||||||
|
currentPage?.pathUrl ?: lastedUrl?.toUri()?.path
|
||||||
|
)
|
||||||
|
binding.pagedLayer.visibility = View.INVISIBLE
|
||||||
|
binding.lunaticBrowser.visibility = View.VISIBLE
|
||||||
|
}, (Random.nextLong(1500L, 3000L) + 1000L))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1168,11 +1232,12 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
binding.lunaticBrowser.binding.internalProgressBar.visibility = GONE
|
binding.lunaticBrowser.binding.internalProgressBar.visibility = GONE
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
applyReaderConfig()
|
applyReaderConfig()
|
||||||
activity?.runOnUiThread {
|
activity?.runOnUiThread {
|
||||||
view.text = contents
|
view.text = contents
|
||||||
view.visibility = VISIBLE
|
view.visibility = if (!saveContinuation) VISIBLE else GONE
|
||||||
binding.lunaticBrowser.visibility = GONE
|
binding.lunaticBrowser.visibility = if (saveContinuation) VISIBLE else GONE
|
||||||
}
|
}
|
||||||
// view.forceUpdateUI()
|
// view.forceUpdateUI()
|
||||||
lastedUrl?.let {
|
lastedUrl?.let {
|
||||||
@ -1181,6 +1246,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
HistoryManager.getBookPageInfo(contentsType,it) {
|
HistoryManager.getBookPageInfo(contentsType,it) {
|
||||||
it?.let {
|
it?.let {
|
||||||
this@TokiFragment.currentPage = it
|
this@TokiFragment.currentPage = it
|
||||||
|
|
||||||
currentChapter = it?.chapterNum ?: 0
|
currentChapter = it?.chapterNum ?: 0
|
||||||
view.currentPage = it?.chapterNum ?: 0
|
view.currentPage = it?.chapterNum ?: 0
|
||||||
HistoryManager.save(
|
HistoryManager.save(
|
||||||
@ -1194,8 +1260,29 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
HistoryManager.getBooPageInfoContentsSave(contentsType,it, contents)
|
HistoryManager.getBooPageInfoContentsSave(contentsType,it, contents)
|
||||||
}
|
}
|
||||||
if (saveContinuation) {
|
if (saveContinuation) {
|
||||||
binding.lunaticBrowser.geckoWeb.postDelayed({moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)}, 10000)
|
onLongClick()
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
binding.pagedLayer.visibility = View.INVISIBLE
|
||||||
|
binding.lunaticBrowser.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
for (i in 0..24) {
|
||||||
|
if (saveContinuation && binding.lunaticBrowser.geckoWeb.scrollState < 1) {
|
||||||
|
binding.lunaticBrowser.geckoWeb.pageDown()
|
||||||
|
}
|
||||||
|
if (saveContinuation && binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
|
||||||
|
binding.pagedLayer.doNext(false)
|
||||||
|
updateLastInfo(binding.pagedLayer!!)
|
||||||
|
}
|
||||||
|
if (saveContinuation) {
|
||||||
|
delay(Random.nextLong(1500L, 3000L) + 1000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (saveContinuation) {
|
||||||
|
binding.pagedLayer.visibility = View.VISIBLE
|
||||||
|
binding.lunaticBrowser.visibility = View.VISIBLE
|
||||||
|
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1203,6 +1290,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log.i(TAG, "onLoadedContents >> " + aContents)
|
// Log.i(TAG, "onLoadedContents >> " + aContents)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1229,24 +1317,95 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
|||||||
) { dialog, which -> dialog.cancel() }
|
) { dialog, which -> dialog.cancel() }
|
||||||
builder.show()
|
builder.show()
|
||||||
}
|
}
|
||||||
var testRegex = """[^0-9]""".toRegex()
|
// --- 여기서부터 파싱 로직 개선 ---
|
||||||
Blog.LOGI(TAG, "onFindTitle >> " + contents + " ::: ${testRegex.replace(contents, "")}")
|
val cleanContents = contents.trim()
|
||||||
if (contents.contains("-")) {
|
|
||||||
currentTitle = contents.split("-")[0]
|
// 1. "숫자 + 화/편/회/장" 패턴을 찾습니다. (예: "1화 어더구", "소설제목 15화", "123편")
|
||||||
try {
|
val chapterPattern = Regex("""(\d+)\s*(?:화|편|회|장)""")
|
||||||
currentChapter = testRegex.replace(contents.split("-")[1], "").toInt()
|
val matchResult = chapterPattern.find(cleanContents)
|
||||||
} catch (e: Exception) {
|
|
||||||
currentChapter = 0
|
if (matchResult != null) {
|
||||||
|
// 정규식으로 찾은 숫자 부분만 추출 (예: "15화" -> 15)
|
||||||
|
currentChapter = matchResult.groupValues[1].toInt()
|
||||||
|
|
||||||
|
// 매칭된 부분(예: "1화")을 기준으로 앞뒤를 분리합니다.
|
||||||
|
val beforeChapter = cleanContents.substring(0, matchResult.range.first).trim()
|
||||||
|
val afterChapter = cleanContents.substring(matchResult.range.last + 1).trim()
|
||||||
|
|
||||||
|
// "1화 어더구" 처럼 앞에 텍스트가 없으면 뒤에 있는 "어더구"를 타이틀로 사용
|
||||||
|
// "소설제목 1화" 처럼 앞에 텍스트가 있으면 앞부분을 타이틀로 사용
|
||||||
|
currentTitle = if (beforeChapter.isNotEmpty()) {
|
||||||
|
beforeChapter.trimEnd('-', ' ', ':')
|
||||||
|
} else if (afterChapter.isNotEmpty()) {
|
||||||
|
afterChapter.trimStart('-', ' ', ':')
|
||||||
|
} else {
|
||||||
|
"제목없음"
|
||||||
}
|
}
|
||||||
} else if (testRegex.replace(contents, "").length > 0) {
|
|
||||||
currentChapter = testRegex.replace(contents, "").toInt()
|
|
||||||
currentTitle = contents.split(testRegex.replace(contents, ""))[0]
|
|
||||||
} else {
|
|
||||||
val dateFormat = "yyyyMMdd-HH"
|
|
||||||
val date = Date(currentTimeMillis())
|
|
||||||
val simpleDateFormat = SimpleDateFormat(dateFormat)
|
|
||||||
currentTitle = simpleDateFormat.format(date)
|
|
||||||
}
|
}
|
||||||
|
// 2. "화" 같은 글자가 없고 하이픈(-)만 있는 경우 (예: "소설제목 - 15")
|
||||||
|
// 2. "화" 같은 글자가 없고 하이픈(-)만 있는 경우 (예: "소설제목 - 15" 또는 "15 - 소설제목")
|
||||||
|
else if (cleanContents.contains("-")) {
|
||||||
|
// 제목 자체에 하이픈이 들어있을 수 있으므로(예: "레벨-업 - 15") 가장 마지막 하이픈을 기준으로 자릅니다.
|
||||||
|
val lastDashIndex = cleanContents.lastIndexOf("-")
|
||||||
|
val frontPart = cleanContents.substring(0, lastDashIndex).trim()
|
||||||
|
val backPart = cleanContents.substring(lastDashIndex + 1).trim()
|
||||||
|
|
||||||
|
// 각 파트에서 순수 문자(한글/영어)만 남김 (길이 비교용)
|
||||||
|
val textOnlyFront = frontPart.replace(Regex("""[^a-zA-Z가-힣]"""), "")
|
||||||
|
val textOnlyBack = backPart.replace(Regex("""[^a-zA-Z가-힣]"""), "")
|
||||||
|
|
||||||
|
// 각 파트에서 숫자만 추출
|
||||||
|
val numFront = frontPart.replace(Regex("""[^0-9]"""), "")
|
||||||
|
val numBack = backPart.replace(Regex("""[^0-9]"""), "")
|
||||||
|
|
||||||
|
// 케이스 A: 뒷부분에 문자(글자)가 없고 숫자만 있다면 -> 앞이 제목, 뒤가 챕터 ("소설제목 - 15")
|
||||||
|
if (textOnlyBack.isEmpty() && numBack.isNotEmpty()) {
|
||||||
|
currentTitle = frontPart
|
||||||
|
currentChapter = numBack.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
// 케이스 B: 앞부분에 문자(글자)가 없고 숫자만 있다면 -> 앞이 챕터, 뒤가 제목 ("15 - 소설제목")
|
||||||
|
else if (textOnlyFront.isEmpty() && numFront.isNotEmpty()) {
|
||||||
|
currentTitle = backPart
|
||||||
|
currentChapter = numFront.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
// 케이스 C: 양쪽 다 문자가 섞여 있다면 ("시즌2 - 15(번외)") -> 문자가 더 긴 쪽을 제목으로 간주
|
||||||
|
else {
|
||||||
|
if (textOnlyFront.length >= textOnlyBack.length) {
|
||||||
|
currentTitle = frontPart
|
||||||
|
currentChapter = numBack.toIntOrNull() ?: 0
|
||||||
|
} else {
|
||||||
|
currentTitle = backPart
|
||||||
|
currentChapter = numFront.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. 위의 패턴에 모두 안 맞는 경우 (마지막 안전장치)
|
||||||
|
else {
|
||||||
|
val numberString = cleanContents.replace(Regex("""[^0-9]"""), "")
|
||||||
|
if (numberString.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
currentChapter = numberString.toInt()
|
||||||
|
currentTitle = cleanContents.replace(numberString, "").trim()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
currentChapter = 0
|
||||||
|
currentTitle = cleanContents
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 아예 숫자가 없는 경우 타임스탬프 사용
|
||||||
|
currentChapter = 0
|
||||||
|
val dateFormat = "yyyyMMdd-HH"
|
||||||
|
val simpleDateFormat = SimpleDateFormat(dateFormat)
|
||||||
|
currentTitle = simpleDateFormat.format(Date(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최종적으로 타이틀이 비어있거나 기호만 남았다면 기본값 처리
|
||||||
|
if (currentTitle.isEmpty() || currentTitle.replace(Regex("""[^a-zA-Z0-9가-힣]"""), "").isEmpty()) {
|
||||||
|
currentTitle = "제목없음"
|
||||||
|
currentChapter = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Blog.LOGI(TAG, "onFindTitle Result >> Title: [$currentTitle], Chapter: [$currentChapter]")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -177,7 +177,7 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
|
|||||||
hanler.removeCallbacks(touchTimeover)
|
hanler.removeCallbacks(touchTimeover)
|
||||||
setOnLongClickListener { v ->
|
setOnLongClickListener { v ->
|
||||||
mPagedTextViewInterface?.onLongClick()
|
mPagedTextViewInterface?.onLongClick()
|
||||||
return@setOnLongClickListener false
|
return@setOnLongClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
setOnTouchListener(SimpleFingerGestures(omfgl = object : SimpleFingerGestures.OnFingerGestureListener{
|
setOnTouchListener(SimpleFingerGestures(omfgl = object : SimpleFingerGestures.OnFingerGestureListener{
|
||||||
@ -240,7 +240,7 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
|
|||||||
gestureDuration: Long,
|
gestureDuration: Long,
|
||||||
gestureDistance: Double
|
gestureDistance: Double
|
||||||
): Boolean {
|
): Boolean {
|
||||||
mPagedTextViewInterface?.onLongClick()
|
// mPagedTextViewInterface?.onLongClick()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,9 +249,9 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
|
|||||||
fingers: Int
|
fingers: Int
|
||||||
): Boolean {
|
): Boolean {
|
||||||
hanler.removeCallbacks(touchTimeover)
|
hanler.removeCallbacks(touchTimeover)
|
||||||
mPagedTextViewInterface?.onLongClick()
|
// mPagedTextViewInterface?.onLongClick()
|
||||||
hanler?.postDelayed(touchTimeover, 3000L)
|
hanler?.postDelayed(touchTimeover, 3000L)
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLongPress(
|
override fun onLongPress(
|
||||||
@ -386,6 +386,7 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun doPrev(fast : Boolean = false) {
|
fun doPrev(fast : Boolean = false) {
|
||||||
if (fast) {
|
if (fast) {
|
||||||
setPageBy(if((this@PagedTextLayout.currentPage - getFastPageCount()) >= 0) {
|
setPageBy(if((this@PagedTextLayout.currentPage - getFastPageCount()) >= 0) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package bums.lunatic.launcher.utils
|
|||||||
|
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
@ -96,4 +97,41 @@ object CompressStringUtil {
|
|||||||
}
|
}
|
||||||
return bytes
|
return bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
class NaturalOrderComparator : Comparator<File> {
|
||||||
|
override fun compare(f1: File, f2: File): Int {
|
||||||
|
// 숫자와 문자의 경계를 기준으로 문자열을 쪼개는 정규식
|
||||||
|
val splitRegex = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)")
|
||||||
|
val parts1 = f1.name.split(splitRegex)
|
||||||
|
val parts2 = f2.name.split(splitRegex)
|
||||||
|
|
||||||
|
val limit = minOf(parts1.size, parts2.size)
|
||||||
|
for (i in 0 until limit) {
|
||||||
|
val p1 = parts1[i]
|
||||||
|
val p2 = parts2[i]
|
||||||
|
|
||||||
|
if (p1 == p2) continue
|
||||||
|
|
||||||
|
val isNum1 = p1.all { it.isDigit() }
|
||||||
|
val isNum2 = p2.all { it.isDigit() }
|
||||||
|
|
||||||
|
if (isNum1 && isNum2) {
|
||||||
|
// 둘 다 숫자면 앞의 0을 떼고 자리수 먼저 비교 후 문자열 비교
|
||||||
|
val num1 = p1.trimStart('0')
|
||||||
|
val num2 = p2.trimStart('0')
|
||||||
|
val cmp = if (num1.length != num2.length) {
|
||||||
|
num1.length.compareTo(num2.length)
|
||||||
|
} else {
|
||||||
|
num1.compareTo(num2)
|
||||||
|
}
|
||||||
|
if (cmp != 0) return cmp
|
||||||
|
} else {
|
||||||
|
// 숫자가 아니면 일반 문자열(대소문자 무시) 비교
|
||||||
|
val strCmp = p1.compareTo(p2, ignoreCase = true)
|
||||||
|
if (strCmp != 0) return strCmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts1.size.compareTo(parts2.size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -81,10 +81,7 @@ class RuliWebGetter(context: Context) : BaseGetter(context) {
|
|||||||
.header("pragma", "no-cache")
|
.header("pragma", "no-cache")
|
||||||
.ignoreContentType(true)
|
.ignoreContentType(true)
|
||||||
.get().let { ruli ->
|
.get().let { ruli ->
|
||||||
// Blog.LOGE(TAG.plus("test ${testUrl2} >> ${ruli.title()}"))
|
ruli.getElementsByClass("table_body blocktarget").forEach { ruli_tr ->}
|
||||||
ruli.getElementsByClass("table_body blocktarget").forEach { ruli_tr ->
|
|
||||||
parseRuli(ruli_tr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e:Exception){e.printStackTrace()}}
|
} catch (e:Exception){e.printStackTrace()}}
|
||||||
|
|||||||
@ -240,7 +240,7 @@ class TorrentService : Service() {
|
|||||||
if (tasks.isNotEmpty()) {
|
if (tasks.isNotEmpty()) {
|
||||||
updateNotification(tasks)
|
updateNotification(tasks)
|
||||||
}
|
}
|
||||||
delay(1000)
|
delay(15000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,16 +4,6 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="?android:attr/windowBackground">
|
android:background="?android:attr/windowBackground">
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
|
|
||||||
android:text="다운로드 보관함"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:padding="16dp"/>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@ -21,6 +11,29 @@
|
|||||||
android:paddingHorizontal="16dp"
|
android:paddingHorizontal="16dp"
|
||||||
android:paddingBottom="8dp"
|
android:paddingBottom="8dp"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="다운로드 보관함"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:padding="16dp"/>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnOrganize"
|
||||||
|
style="@style/MaterialIconButtonStyle"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="auto_awesome" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnViewMode"
|
android:id="@+id/btnViewMode"
|
||||||
@ -31,19 +44,24 @@
|
|||||||
|
|
||||||
<Space
|
<Space
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"/>
|
android:layout_height="wrap_content"/>
|
||||||
<Spinner
|
|
||||||
android:id="@+id/spinnerFilter"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1" />
|
|
||||||
|
|
||||||
<Spinner
|
<androidx.appcompat.widget.AppCompatSpinner
|
||||||
android:id="@+id/spinnerSort"
|
android:id="@+id/spinnerFilter"
|
||||||
android:layout_width="0dp"
|
android:layout_width="90dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1" />
|
android:background="@android:color/transparent"
|
||||||
|
android:spinnerMode="dropdown"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatSpinner
|
||||||
|
android:id="@+id/spinnerSort"
|
||||||
|
android:layout_width="90dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@android:color/transparent"
|
||||||
|
android:spinnerMode="dropdown"
|
||||||
|
/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvSortOrder"
|
android:id="@+id/tvSortOrder"
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:id="@android:id/text1"
|
android:id="@android:id/text1"
|
||||||
android:layout_margin="10dp"
|
android:layout_margin="6dp"
|
||||||
android:padding="10dp"
|
android:padding="6dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:textColor="@color/bottom_option"
|
android:textColor="@color/bottom_option"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user