This commit is contained in:
lunaticbum 2026-04-21 15:37:32 +09:00
parent fd0ee96c04
commit ac1c0b12b3
10 changed files with 201 additions and 86 deletions

View File

@ -13,6 +13,10 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import android.os.Bundle
@ -46,6 +50,7 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import bums.lunatic.launcher.LunaticLauncher.Companion.isWifiConnected
import bums.lunatic.launcher.apps.AppDrawerBottomSheet
import bums.lunatic.launcher.common.CommonActivity
import bums.lunatic.launcher.databinding.LauncherActivityBinding
@ -64,6 +69,7 @@ import bums.lunatic.launcher.receiver.NLService
import bums.lunatic.launcher.receiver.SmsReceiver
import bums.lunatic.launcher.settings.SettingsActivity
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.workers.BaseGetter
import bums.lunatic.launcher.workers.TorrentService
import bums.lunatic.launcher.workers.UsageLogType
import bums.lunatic.launcher.workers.UsageUpdateType
@ -580,8 +586,54 @@ open class LauncherActivity : CommonActivity() {
))
handleSharedIntent(intent)
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
LunaticLauncher.isWifiConnected = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
registerNetworkCallback()
}
private fun registerNetworkCallback() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
// 단순히 caps만 보지 말고, 현재 활성화된 기본 네트워크의 상태를 직접 다시 조회합니다.
val activeNet = connectivityManager.activeNetwork
val activeCaps = connectivityManager.getNetworkCapabilities(activeNet)
val wifiNow = activeCaps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
// 상태가 실제로 변했을 때만 업데이트하여 불필요한 로그와 로직 실행을 방지합니다.
if (isWifiConnected != wifiNow) {
isWifiConnected = wifiNow
}
val intent = Intent(this@LauncherActivity, TorrentService::class.java).apply {
putExtra("WIFI_STATE", isWifiConnected)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
// 네트워크가 완전히 끊겼을 때도 처리해주는 것이 안전합니다.
override fun onLost(network: Network) {
val intent = Intent(this@LauncherActivity, TorrentService::class.java).apply {
putExtra("WIFI_STATE", false)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
connectivityManager.registerNetworkCallback(request, networkCallback!!)
}
private var smsReceiver: SmsReceiver? = null
// 권한 요청 결과 처리기
@ -911,12 +963,15 @@ open class LauncherActivity : CommonActivity() {
appWidgetHost?.stopListening() // [필수] 여기서 리스닝 중지 (onDestroy 대신 여기 추천)
}
private lateinit var connectivityManager: ConnectivityManager
private var networkCallback: ConnectivityManager.NetworkCallback? = null
override fun onDestroy() {
smsReceiver?.let {
unregisterReceiver(it)
smsReceiver = null
}
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
super.onDestroy()
}

View File

@ -51,6 +51,7 @@ internal class LunaticLauncher : Application() {
companion object {
var appContext : LunaticLauncher? = null
var mHourlyLogWriter : HourlyLogWriter? = null
var isWifiConnected : Boolean = false
private var sRuntime: GeckoRuntime? = null
fun getRuntime() : GeckoRuntime? {
appContext?.initGeckoRuntime()

View File

@ -77,6 +77,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Calendar
import kotlin.math.abs
data class ZenQuoteResponse(val q: String, val a: String)
data class KorAdviceResponse(val author: String, val message: String)
@ -515,6 +516,7 @@ class ForeGroundService : Service() {
}
fun addToTargetYtubeUrl(url : String? = null, forMusic: Boolean = false) {
Blog.LOGE("url $url")
url?.let { url ->
if (url.length > 0) {
targetUrls.add(url to forMusic)
@ -537,6 +539,7 @@ class ForeGroundService : Service() {
}
}
var lastProGress = -1f
fun downloadVideo(url: String?, forMusic: Boolean = false) {
url?.let {
CoroutineScope(Dispatchers.IO).launch {
@ -547,35 +550,42 @@ class ForeGroundService : Service() {
val youtubeDLDir = File(baseDir, "Youtube")
if (!youtubeDLDir.exists()) youtubeDLDir.mkdirs()
val command = YoutubeDLRequest(url).apply {
addOption("-q") // 로그 최소화
addOption("--newline") // 줄바꿈 단위로 출력 (콜백 트리거 핵심)
addOption("--progress") // 진행 상태 강제 출력 // 로그 최소화
addOption("--no-warnings") // 경고 숨김
val cookieFile = File(this@ForeGroundService.cacheDir, "cookies.txt")
addOption("--cookies", "${cookieFile.absolutePath}")
// 출력 경로
addOption("-o", "${youtubeDLDir.absolutePath}/%(title)s.%(ext)s")
addOption("-o", "${youtubeDLDir.absolutePath}/%(title)s [%(id)s].%(ext)s")
if (forMusic) {
// 음악 전용 옵션
addOption("-x") // 오디오 추출
addOption("--audio-format", "mp3")
addOption("--extractor-args", "youtube:player_client=web_music,android")
addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
} else {
// if (forMusic) {
// // 음악 전용 옵션
// addOption("-x") // 오디오 추출
// addOption("--audio-format", "mp3")
// addOption("--extractor-args", "youtube:player_client=web_music,android")
// addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
// } else {
// 일반 영상용
addOption("-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best")
}
// 안정성 향상 옵션 (모두에 적용)
// }
addOption("--no-check-certificates")
addOption("--verbose")
addOption("--no-mtime") // 파일 수정시간 안 건드림
addOption("--no-continue") // 이어받기 하지 않음
addOption("--force-overwrites") // 강제 덮어쓰기
addOption("--restrict-filenames") // 특수문자 제한
}
currentProcessId = UUID.randomUUID().toString()
YoutubeDL.getInstance()
var response = YoutubeDL.getInstance()
.execute(command, currentProcessId) { progress, est, str ->
startForeGround(100, progress.toInt(), str, false)
// Blog.LOGE("progress $progress est $est str $str" )
if(progress == 100.0f || progress == 0.0f || abs(lastProGress - progress) > 3) {
startForeGround(100, progress.toInt(), str, false)
lastProGress = progress
}
if (progress >= 100) {
currentProcessId = null
if ((targetUrls?.size ?: 0) > 0) {
@ -583,6 +593,10 @@ class ForeGroundService : Service() {
}
}
}
Blog.LOGE("url $url $currentProcessId")
Blog.LOGE("Exit Code: ${response.exitCode}")
Blog.LOGE("Out: ${response.out}")
Blog.LOGE("Error: ${response.err}")
} catch (e: Exception) {
Blog.LOGE("Download Error", e)
currentProcessId = null
@ -710,7 +724,7 @@ class ForeGroundService : Service() {
.build()
// 2. 뉴스 피드: 사용자가 설정한 간격 (예: 1시간)
val newsRequest = PeriodicWorkRequestBuilder<AggregatedNewsWorker>(PrefLong.shortTimePeriod.get(20L), TimeUnit.MINUTES)
val newsRequest = PeriodicWorkRequestBuilder<AggregatedNewsWorker>(PrefLong.longTimePeriod.get(120L), TimeUnit.MINUTES)
.build()
// 기존의 수많은 enqueue 코드를 이 두 개로 대체

View File

@ -891,12 +891,9 @@ class CompletedFilesFragment : Fragment() {
val innerFiles = folder.listFiles() ?: return@forEach
// 특수 조건(1GB 영상) 확인용 데이터
val videoFiles = innerFiles.filter { extVideos.contains(it.extension.lowercase()) }
val videoFiles = innerFiles.filter { extVideos.contains(it.extension.lowercase()) }.filter { it.length() >= 1024 * 1024 * 1024 }
val potentialSubtitles = innerFiles.filter { subtitleExts.contains(it.extension.lowercase()) }
val hasLargeVideo = videoFiles.any { it.length() >= 1024 * 1024 * 1024 }
val hasTinyText = potentialSubtitles.any { it.length() <= 1024 }
if (hasLargeVideo) {
if (videoFiles.isNotEmpty()) {
// 조건 만족 시 영상+자막 이동
videoFiles.forEach { videoFile ->
if (videoFile.renameTo(File(videoTargetDir, videoFile.name))) {

View File

@ -282,17 +282,17 @@ open class GeckoWeb @JvmOverloads constructor(
val cookieFile = File(context.cacheDir, "cookies.txt")
addOption("--cookies", "${cookieFile.absolutePath}")
if (forMusic) {
addOption("-x") // 오디오 추출
addOption("--audio-format", "mp3")
// YouTube Music 전용 extractor arg 추가
addOption("--extractor-args", "youtube:player_client=web_music,android")
// 음악 스트림 우선 선택
addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
} else {
// if (forMusic) {
// addOption("-x") // 오디오 추출
// addOption("--audio-format", "mp3")
// // YouTube Music 전용 extractor arg 추가
// addOption("--extractor-args", "youtube:player_client=web_music,android")
// // 음악 스트림 우선 선택
// addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
// } else {
// 일반 영상용 기본 포맷
addOption("-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best")
}
// }
// 디버깅용 (테스트 후 제거)
@ -476,6 +476,7 @@ open class GeckoWeb @JvmOverloads constructor(
override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) {
lastSessionState = sessionState
onSessionStateChangeCallback?.invoke(sessionState)
saveCurrentSessionState()
// Blog.LOGE("onSessionStateChange $sessionState ${session}")
}
}
@ -661,7 +662,7 @@ open class GeckoWeb @JvmOverloads constructor(
}
}
"COOKIES_REPORT"-> {
// Blog.LOGE("${msg.value} -> ${msg.url}")
Blog.LOGE("${msg.value} -> ${msg.url}")
currentCookieString = msg.value ?: ""
currentCookieUrlString = msg.url ?: ""
CoroutineScope(Dispatchers.IO).launch {

View File

@ -496,6 +496,7 @@ open class NeoRssActivity : CommonActivity() {
super.onCreate(savedInstanceState)
try {
YoutubeDL.getInstance().init(this)
FFmpeg.getInstance().init(this)
CoroutineScope(Dispatchers.IO).launch {
try {

View File

@ -149,8 +149,11 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
if (allSubtitleTracks.size > 1) {
showSubtitleSelectionDialog()
} else {
showSubtitleSearchConfirmDialog()
if (videoPath.contains("Youtube")) {
play()
} else {
showSubtitleSearchConfirmDialog()
}
}
}
}
@ -368,10 +371,28 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
val center = gestureLayer.getChildAt(1)
val right = gestureLayer.getChildAt(2)
center.setOnClickListener {
isPlaying = !isPlaying
if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
else nativePlayer?.pause()
val centerDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
isPlaying = !isPlaying
if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
else nativePlayer?.pause()
return true
}
// 좌우 스크롤로 자막 싱크 조절
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
if (Math.abs(distanceX) > Math.abs(distanceY)) {
if (distanceX > 0) adjustSubtitleSync(-500) // 왼쪽으로 밀면 자막을 빠르게
else adjustSubtitleSync(500) // 오른쪽으로 밀면 자막을 느리게
return true
}
return false
}
})
center.setOnTouchListener { _, event ->
centerDetector.onTouchEvent(event)
true
}
val rightDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
@ -411,7 +432,17 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
true
}
}
private var subtitleDelayMs: Long = 0L
private fun adjustSubtitleSync(deltaMs: Long) {
subtitleDelayMs += deltaMs
val seconds = subtitleDelayMs / 1000.0
val sign = if (subtitleDelayMs >= 0) "+" else ""
Toast.makeText(this, "자막 싱크: $sign${String.format("%.1f", seconds)}", Toast.LENGTH_SHORT).show()
// 싱크가 변경되면 즉시 루프에서 반영되므로 별도의 처리는 필요 없으나,
// 즉각적인 피드백을 위해 lastSubTitle을 초기화하여 강제 갱신 유도 가능
lastSubTitle = ""
}
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
hideSystemUI()
@ -640,23 +671,21 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
* 💡 인덱스 기반 탐색: 이전 위치부터 찾기 때문에 CPU 부하가 거의 없습니다.
*/
private fun findSubtitleIndexed(currentSec: Double): SubtitleBlock? {
// 1. 영상이 뒤로 감기 되었을 경우 인덱스 초기화
// 💡 싱크 오프셋 적용 (초 단위로 변환하여 더함)
val adjustedSec = currentSec - (subtitleDelayMs / 1000.0)
if (currentSubtitleIndex >= externalSubtitles.size ||
externalSubtitles[currentSubtitleIndex].startSec > currentSec) {
externalSubtitles[currentSubtitleIndex].startSec > adjustedSec) {
currentSubtitleIndex = 0
}
// 2. 마지막으로 찾았던 위치(currentSubtitleIndex)부터 탐색 시작
for (i in currentSubtitleIndex until externalSubtitles.size) {
val item = externalSubtitles[i]
if (currentSec in item.startSec..item.endSec) {
currentSubtitleIndex = i // 현재 위치 저장
if (adjustedSec in item.startSec..item.endSec) {
currentSubtitleIndex = i
return item
}
// 3. 자막이 시간순으로 정렬되어 있다면, 현재 시간보다 시작 시간이 커지는 순간 루프 종료
if (item.startSec > currentSec) break
if (item.startSec > adjustedSec) break
}
return null
}

View File

@ -24,6 +24,8 @@ abstract class BaseGetter(internal val context: Context) {
}
}
val USAGT = "Mozilla/5.0 (Android 15; Mobile; rv:141.0) Gecko/141.0 Firefox/141.0"
val limitDateTime = beforeOneDay()
@ -32,6 +34,7 @@ abstract class BaseGetter(internal val context: Context) {
abstract fun realWork() : List<RealmObject>
open suspend fun fetchData(): List<RealmObject> {
return realWork()
}
}

View File

@ -93,11 +93,8 @@ object TaskAggregator {
// 병렬로 네트워크 요청 쏘기 (시간 획기적으로 단축됨)
val jobs = listOf(
// async { RuliWebGetter(context).fetchData() },
async { TheQooGetter(context).fetchData() },
// async { YoutubeGetter(context).fetchData() },
async { DCGetter(context).fetchData() },
// async { FmKoreaGetter(context).fetchData() },
async { NewsFeedsGetter(context).fetchData() },
async { ClienGetter(context).fetchData() },
async { DotaxGetter(context).fetchData() },

View File

@ -16,7 +16,7 @@ import com.frostwire.jlibtorrent.swig.settings_pack
import com.frostwire.jlibtorrent.swig.torrent_status
import kotlinx.coroutines.*
import java.io.File
import android.telephony.TelephonyManager
data class TorrentTask(
val infoHash: String,
val name: String,
@ -45,8 +45,7 @@ class TorrentService : Service() {
// 제어 플래그
private var isWifiConnected = false
private var isCharging = false
private lateinit var connectivityManager: ConnectivityManager
private var networkCallback: ConnectivityManager.NetworkCallback? = null
inner class TorrentBinder : Binder() {
fun getService(): TorrentService = this@TorrentService
@ -56,6 +55,12 @@ class TorrentService : Service() {
override fun onCreate() {
super.onCreate()
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (!isKoreaRegion()) {
Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
stopForeground(true)
stopSelf()
return
}
startForegroundService()
initLibTorrent()
@ -64,7 +69,7 @@ class TorrentService : Service() {
// 2. 리시버 및 콜백 등록
registerBatteryReceiver()
registerNetworkCallback()
// 3. 초기 세션 상태 적용
updateSessionState()
@ -79,11 +84,7 @@ class TorrentService : Service() {
val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
// Wi-Fi 상태
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
isWifiConnected = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}
private fun registerBatteryReceiver() {
@ -151,34 +152,7 @@ class TorrentService : Service() {
}
}
private fun registerNetworkCallback() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
// 단순히 caps만 보지 말고, 현재 활성화된 기본 네트워크의 상태를 직접 다시 조회합니다.
val activeNet = connectivityManager.activeNetwork
val activeCaps = connectivityManager.getNetworkCapabilities(activeNet)
val wifiNow = activeCaps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
// 상태가 실제로 변했을 때만 업데이트하여 불필요한 로그와 로직 실행을 방지합니다.
if (isWifiConnected != wifiNow) {
isWifiConnected = wifiNow
updateSessionState()
}
}
// 네트워크가 완전히 끊겼을 때도 처리해주는 것이 안전합니다.
override fun onLost(network: Network) {
checkInitialStatus() // 전체 상태 다시 체크
updateSessionState()
}
}
connectivityManager.registerNetworkCallback(request, networkCallback!!)
}
var lastUpdateTime = -1L
@ -186,6 +160,15 @@ class TorrentService : Service() {
* 핵심 제어 로직: 충전 중일 때만 세션을 열고, Wi-Fi 여부에 따라 슬롯 조절
*/
private fun updateSessionState() {
if (!isKoreaRegion()) {
Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
stopForeground(true)
stopSelf()
return
}
checkIpAndStop()
if (session.isPaused) session.resume()
var curentTime = System.currentTimeMillis()
if (curentTime - lastUpdateTime > 5000) {
@ -247,7 +230,38 @@ class TorrentService : Service() {
refreshTorrentStats()
}
}
}
private fun isKoreaRegion(): Boolean {
val tm = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
// 1. SIM 카드의 국가 코드 확인 (예: "kr")
val simCountry = tm.simCountryIso.lowercase()
// 2. 네트워크(기지국) 기준 국가 코드 확인
val networkCountry = tm.networkCountryIso.lowercase()
// SIM이나 네트워크 중 하나라도 'kr'이면 한국으로 판단
return simCountry == "kr" || networkCountry == "kr"
}
private fun checkIpAndStop() {
serviceScope.launch(Dispatchers.IO) {
try {
// 외부 API를 통해 국가 코드 확인 (예: ip-api.com)
val response = java.net.URL("http://ip-api.com/json/").readText()
if (!response.contains("\"countryCode\":\"KR\"")) {
withContext(Dispatchers.Main) {
Blog.LOGE("IP 위치가 대한민국이 아닙니다. 다운로드를 중지합니다.")
// 모든 토렌트 일시정지 또는 서비스 종료
session.pause()
stopSelf()
}
}
} catch (e: Exception) {
// 네트워크 오류 시 보수적으로 처리 (선택 사항)
}
}
}
private val TRACKER_URL = "https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt"
@ -518,6 +532,10 @@ class TorrentService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.getStringExtra("EXTRA_MAGNET_URI")?.let { addMagnet(it) }
intent?.getBooleanExtra("WIFI_STATE", false)?.let {
isWifiConnected = it
}
return START_STICKY
}
@ -562,7 +580,6 @@ class TorrentService : Service() {
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(batteryReceiver)
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
serviceScope.cancel()
}