diff --git a/tvmon-app/app/build.gradle b/tvmon-app/app/build.gradle
index 8aaf3a4..b884b34 100644
--- a/tvmon-app/app/build.gradle
+++ b/tvmon-app/app/build.gradle
@@ -13,8 +13,8 @@ android {
applicationId "com.example.tvmon"
minSdk 28
targetSdk 34
- versionCode 1
- versionName "1.0.0"
+ versionCode 2
+ versionName "1.0.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/tvmon-app/app/src/main/AndroidManifest.xml b/tvmon-app/app/src/main/AndroidManifest.xml
index c39d08b..6990825 100644
--- a/tvmon-app/app/src/main/AndroidManifest.xml
+++ b/tvmon-app/app/src/main/AndroidManifest.xml
@@ -55,19 +55,35 @@
android:theme="@style/Theme.Tvmon.Search" />
-
-
-
-
-
+ android:name=".ui.search.SearchActivity"
+ android:exported="true"
+ android:label="@string/search_title"
+ android:screenOrientation="landscape"
+ android:theme="@style/Theme.Tvmon.Search">
+
+
+
+
+
-
+
+
+
+
+
+
+
diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/CategoryContentDao.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/CategoryContentDao.kt
index d6e59df..ba2dec3 100644
--- a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/CategoryContentDao.kt
+++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/CategoryContentDao.kt
@@ -5,11 +5,11 @@ import com.example.tvmon.data.local.entity.CategoryContent
@Dao
interface CategoryContentDao {
- @Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey ORDER BY cachedAt ASC")
- suspend fun getByCategory(categoryKey: String): List
+@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey")
+suspend fun getByCategory(categoryKey: String): List
- @Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page ORDER BY cachedAt ASC")
- suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List
+@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page")
+suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List
@Query("SELECT MAX(pageNumber) FROM category_content WHERE categoryKey = :categoryKey")
suspend fun getMaxPage(categoryKey: String): Int?
diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt
index 68605a7..5dfc0f9 100644
--- a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt
+++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt
@@ -142,17 +142,19 @@ private fun extractSeriesUrl(url: String): String {
result
}
- suspend fun getCategory(categoryKey: String, page: Int = 1): CategoryResult = withContext(Dispatchers.IO) {
- val catInfo = CATEGORIES[categoryKey] ?: return@withContext CategoryResult(
- success = false,
- category = "Unknown",
- items = emptyList(),
- page = page,
- pagination = Pagination(1, 1)
- )
+suspend fun getCategory(categoryKey: String, page: Int = 1): CategoryResult = withContext(Dispatchers.IO) {
+ val catInfo = CATEGORIES[categoryKey] ?: return@withContext CategoryResult(
+ success = false,
+ category = "Unknown",
+ items = emptyList(),
+ page = page,
+ pagination = Pagination(1, 1)
+ )
- val url = if (page == 1) "$BASE_URL${catInfo.path}" else "$BASE_URL${catInfo.path}?page=$page"
- val html = get(url) ?: return@withContext CategoryResult(
+ // Sort by wr_datetime (latest first) - same as website default sorting
+ val sortParam = "sst=wr_datetime&sod=desc"
+ val url = if (page == 1) "$BASE_URL${catInfo.path}?$sortParam" else "$BASE_URL${catInfo.path}?$sortParam&page=$page"
+ val html = get(url) ?: return@withContext CategoryResult(
success = false,
category = catInfo.name,
items = emptyList(),
diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt
index 5a1191d..b9b9222 100644
--- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt
+++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt
@@ -15,21 +15,24 @@ import com.example.tvmon.data.repository.WatchHistoryRepository
import com.example.tvmon.data.scraper.TvmonScraper
import com.example.tvmon.ui.detail.DetailsActivity
import com.example.tvmon.ui.presenter.ContentCardPresenter
+import com.example.tvmon.ui.settings.SettingsActivity
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemViewSelectedListener {
- companion object {
- private const val TAG = "TVMON_MAIN"
- }
+companion object {
+private const val TAG = "TVMON_MAIN"
+}
- private val categoryCacheRepository: CategoryCacheRepository by inject()
- private val watchHistoryRepository: WatchHistoryRepository by inject()
- private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
- private val categoryRowAdapters = mutableMapOf()
- private val loadingStates = mutableMapOf()
- private var isDataLoaded = false
+data class SettingsItem(val title: String = "설정")
+
+private val categoryCacheRepository: CategoryCacheRepository by inject()
+private val watchHistoryRepository: WatchHistoryRepository by inject()
+private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
+private val categoryRowAdapters = mutableMapOf()
+private val loadingStates = mutableMapOf()
+private var isDataLoaded = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -99,13 +102,26 @@ private fun setupUI() {
if (success) successCount++ else failCount++
}
- Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===")
- isDataLoaded = true
+Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===")
+isDataLoaded = true
- if (rowsAdapter.size() == 0) {
- Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show()
- }
- }
+if (rowsAdapter.size() == 0) {
+Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show()
+}
+
+activity?.runOnUiThread {
+addSettingsRow()
+}
+}
+
+private fun addSettingsRow() {
+val settingsAdapter = ArrayObjectAdapter(ContentCardPresenter())
+settingsAdapter.add(SettingsItem())
+val settingsHeader = HeaderItem("설정")
+val settingsRow = ListRow(settingsHeader, settingsAdapter)
+rowsAdapter.add(settingsRow)
+Log.w(TAG, "ADDED SETTINGS ROW")
+}
private suspend fun loadCategoryWithCache(category: com.example.tvmon.data.model.Category): Boolean {
return try {
@@ -139,26 +155,30 @@ private fun setupUI() {
}
}
- override fun onItemClicked(
- itemViewHolder: Presenter.ViewHolder?,
- item: Any?,
- rowViewHolder: RowPresenter.ViewHolder?,
- row: Row?
- ) {
- Log.w(TAG, "=== onItemClicked: item=$item ===")
- when (item) {
- is Content -> {
- Log.w(TAG, "Content clicked: ${item.title}, url=${item.url}")
- val intent = Intent(requireContext(), DetailsActivity::class.java).apply {
- putExtra(DetailsActivity.EXTRA_CONTENT, item)
- }
- startActivity(intent)
- }
- else -> {
- Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}")
- }
- }
- }
+override fun onItemClicked(
+itemViewHolder: Presenter.ViewHolder?,
+item: Any?,
+rowViewHolder: RowPresenter.ViewHolder?,
+row: Row?
+) {
+Log.w(TAG, "=== onItemClicked: item=$item ===")
+when (item) {
+is Content -> {
+Log.w(TAG, "Content clicked: ${item.title}, url=${item.url}")
+val intent = Intent(requireContext(), DetailsActivity::class.java).apply {
+putExtra(DetailsActivity.EXTRA_CONTENT, item)
+}
+startActivity(intent)
+}
+is SettingsItem -> {
+Log.w(TAG, "Settings clicked")
+startActivity(Intent(requireContext(), SettingsActivity::class.java))
+}
+else -> {
+Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}")
+}
+}
+}
override fun onItemSelected(
itemViewHolder: Presenter.ViewHolder?,
diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt
index 60991db..138c43e 100644
--- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt
+++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt
@@ -153,152 +153,158 @@ class PlaybackActivity : AppCompatActivity() {
webView.loadUrl(url)
}
- @SuppressLint("SetJavaScriptEnabled")
- private fun setupWebView() {
- val webSettings: WebSettings = webView.settings
+@SuppressLint("SetJavaScriptEnabled")
+private fun setupWebView() {
+val webSettings: WebSettings = webView.settings
- webSettings.javaScriptEnabled = true
- webSettings.domStorageEnabled = true
- webSettings.databaseEnabled = true
- webSettings.allowFileAccess = true
- webSettings.allowContentAccess = true
- webSettings.mediaPlaybackRequiresUserGesture = false
- webSettings.loadsImagesAutomatically = true
- webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
- webSettings.userAgentString = USER_AGENT
- webSettings.useWideViewPort = true
- webSettings.loadWithOverviewMode = true
- webSettings.cacheMode = WebSettings.LOAD_DEFAULT
- webSettings.allowUniversalAccessFromFileURLs = true
- webSettings.allowFileAccessFromFileURLs = true
+webSettings.javaScriptEnabled = true
+webSettings.domStorageEnabled = true
+webSettings.databaseEnabled = true
+webSettings.allowFileAccess = true
+webSettings.allowContentAccess = true
+webSettings.mediaPlaybackRequiresUserGesture = false
+webSettings.loadsImagesAutomatically = true
+webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+webSettings.userAgentString = USER_AGENT
+webSettings.useWideViewPort = true
+webSettings.loadWithOverviewMode = true
+webSettings.cacheMode = WebSettings.LOAD_DEFAULT
+webSettings.allowUniversalAccessFromFileURLs = true
+webSettings.allowFileAccessFromFileURLs = true
- webView.addJavascriptInterface(object {
- @android.webkit.JavascriptInterface
- fun hideLoadingOverlay() {
- runOnUiThread {
- loadingOverlay.visibility = View.GONE
- }
- }
-
- @android.webkit.JavascriptInterface
- fun isVideoPlaying() {
- webView.evaluateJavascript(
- """
- (function() {
- var videos = document.querySelectorAll('video');
- var playing = false;
- videos.forEach(function(v) {
- if (!v.paused && v.currentTime > 0) {
- playing = true;
- }
- });
- return playing ? 'playing' : 'not_playing';
- })();
- """.trimIndent()
- ) { result ->
- android.util.Log.i("PlaybackActivity", "Video playing status: $result")
- if (result == "\"playing\"") {
- runOnUiThread {
- loadingOverlay.visibility = View.GONE
- }
- }
- }
- }
- @android.webkit.JavascriptInterface
- fun onVideoPlay() {
- runOnUiThread {
- loadingOverlay.visibility = View.GONE
- android.util.Log.d("PlaybackActivity", "Video started playing")
- }
- }
- }, "Android")
+// Video playback optimizations
+webSettings.mediaPlaybackRequiresUserGesture = false
+webSettings.blockNetworkImage = false
- val cookieManager = CookieManager.getInstance()
- cookieManager.setAcceptCookie(true)
- cookieManager.setAcceptThirdPartyCookies(webView, true)
+webView.addJavascriptInterface(object {
+@android.webkit.JavascriptInterface
+fun hideLoadingOverlay() {
+runOnUiThread {
+loadingOverlay.visibility = View.GONE
+}
+}
- webView.webChromeClient = object : WebChromeClient() {
- override fun onConsoleMessage(cm: ConsoleMessage?): Boolean {
- val message = cm?.message() ?: return false
- android.util.Log.d("WebViewConsole", "[$${cm.sourceId()}:${cm.lineNumber()}] $message")
+@android.webkit.JavascriptInterface
+fun isVideoPlaying() {
+webView.evaluateJavascript(
+"""
+(function() {
+var videos = document.querySelectorAll('video');
+var playing = false;
+videos.forEach(function(v) {
+if (!v.paused && v.currentTime > 0) {
+playing = true;
+}
+});
+return playing ? 'playing' : 'not_playing';
+})();
+""".trimIndent()
+) { result ->
+android.util.Log.i("PlaybackActivity", "Video playing status: $result")
+if (result == "\"playing\"") {
+runOnUiThread {
+loadingOverlay.visibility = View.GONE
+}
+}
+}
+}
+@android.webkit.JavascriptInterface
+fun onVideoPlay() {
+runOnUiThread {
+loadingOverlay.visibility = View.GONE
+android.util.Log.d("PlaybackActivity", "Video started playing")
+}
+}
+}, "Android")
- if (message.contains("player") || message.contains("video") || message.contains(".m3u8") || message.contains(".mp4")) {
- android.util.Log.i("WebViewConsole", "Player info: $message")
- }
- return true
- }
+val cookieManager = CookieManager.getInstance()
+cookieManager.setAcceptCookie(true)
+cookieManager.setAcceptThirdPartyCookies(webView, true)
- override fun onPermissionRequest(request: android.webkit.PermissionRequest?) {
- android.util.Log.d("PlaybackActivity", "Permission request: ${request?.resources?.joinToString()}")
- request?.grant(request.resources)
- }
- }
+webView.webChromeClient = object : WebChromeClient() {
+override fun onConsoleMessage(cm: ConsoleMessage?): Boolean {
+val message = cm?.message() ?: return false
+android.util.Log.d("WebViewConsole", "[$${cm.sourceId()}:${cm.lineNumber()}] $message")
- webView.webViewClient = object : WebViewClient() {
- override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
- val url = request?.url?.toString() ?: ""
-
- if (url.contains(".css") || url.contains(".js") && url.contains("tvmon.site")) {
- try {
- val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection
- conn.requestMethod = "GET"
- conn.connectTimeout = 8000
- conn.readTimeout = 8000
- conn.setRequestProperty("User-Agent", USER_AGENT)
- conn.setRequestProperty("Referer", "https://tvmon.site/")
-
- val contentType = conn.contentType ?: "text/plain"
- val encoding = conn.contentEncoding ?: "UTF-8"
- val inputStream = conn.inputStream
- val content = inputStream.bufferedReader().use { it.readText() }
- inputStream.close()
-
- var modifiedContent = content
-
- if (contentType.contains("text/html", true)) {
- modifiedContent = injectFullscreenStyles(content)
- }
-
- return WebResourceResponse(
- contentType, encoding,
- 200, "OK",
- mapOf("Access-Control-Allow-Origin" to "*"),
- ByteArrayInputStream(modifiedContent.toByteArray(charset(encoding)))
- )
- } catch (e: Exception) {
- android.util.Log.w("PlaybackActivity", "Intercept failed for $url: ${e.message}")
- }
- }
-
- return super.shouldInterceptRequest(view, request)
- }
+if (message.contains("player") || message.contains("video") || message.contains(".m3u8") || message.contains(".mp4")) {
+android.util.Log.i("WebViewConsole", "Player info: $message")
+}
+return true
+}
- override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
- super.onPageStarted(view, url, favicon)
- runOnUiThread {
- loadingOverlay.visibility = View.VISIBLE
- }
- }
+override fun onPermissionRequest(request: android.webkit.PermissionRequest?) {
+android.util.Log.d("PlaybackActivity", "Permission request: ${request?.resources?.joinToString()}")
+request?.grant(request.resources)
+}
+}
- override fun onPageFinished(view: WebView?, url: String?) {
- super.onPageFinished(view, url)
- android.util.Log.i("PlaybackActivity", "Page finished: $url")
+webView.webViewClient = object : WebViewClient() {
+override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
+val url = request?.url?.toString() ?: ""
- handler.postDelayed({
- injectFullscreenScript()
- injectEnhancedAutoPlayScript()
- }, 200)
+if (url.contains(".css") || url.contains(".js") && url.contains("tvmon.site")) {
+try {
+val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection
+conn.requestMethod = "GET"
+conn.connectTimeout = 8000
+conn.readTimeout = 8000
+conn.setRequestProperty("User-Agent", USER_AGENT)
+conn.setRequestProperty("Referer", "https://tvmon.site/")
- handler.postDelayed({
- runOnUiThread {
- loadingOverlay.visibility = View.GONE
- }
- }, 1000)
- }
- }
+val contentType = conn.contentType ?: "text/plain"
+val encoding = conn.contentEncoding ?: "UTF-8"
+val inputStream = conn.inputStream
+val content = inputStream.bufferedReader().use { it.readText() }
+inputStream.close()
- webView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
- }
+var modifiedContent = content
+
+if (contentType.contains("text/html", true)) {
+modifiedContent = injectFullscreenStyles(content)
+}
+
+return WebResourceResponse(
+contentType, encoding,
+200, "OK",
+mapOf("Access-Control-Allow-Origin" to "*"),
+ByteArrayInputStream(modifiedContent.toByteArray(charset(encoding)))
+)
+} catch (e: Exception) {
+android.util.Log.w("PlaybackActivity", "Intercept failed for $url: ${e.message}")
+}
+}
+
+return super.shouldInterceptRequest(view, request)
+}
+
+override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
+super.onPageStarted(view, url, favicon)
+runOnUiThread {
+loadingOverlay.visibility = View.VISIBLE
+}
+}
+
+override fun onPageFinished(view: WebView?, url: String?) {
+super.onPageFinished(view, url)
+android.util.Log.i("PlaybackActivity", "Page finished: $url")
+
+// Reduced delays and calls to minimize memory pressure
+handler.postDelayed({
+injectFullscreenScript()
+injectEnhancedAutoPlayScript()
+}, 300)
+
+handler.postDelayed({
+runOnUiThread {
+loadingOverlay.visibility = View.GONE
+}
+}, 1500)
+}
+}
+
+// Use hardware layer only when needed, not always
+webView.setLayerType(View.LAYER_TYPE_NONE, null)
+}
private fun injectFullscreenStyles(html: String): String {
val styleInjection = """
@@ -449,12 +455,10 @@ class PlaybackActivity : AppCompatActivity() {
});
};
- makePlayerFullscreen();
- setTimeout(makePlayerFullscreen, 500);
- setTimeout(makePlayerFullscreen, 1500);
- setTimeout(makePlayerFullscreen, 3000);
- setInterval(makePlayerFullscreen, 5000);
- })();
+makePlayerFullscreen();
+setTimeout(makePlayerFullscreen, 500);
+setTimeout(makePlayerFullscreen, 2000);
+})();
""".trimIndent(),
null
)
diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt
index b41b211..87e808e 100644
--- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt
+++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt
@@ -7,6 +7,8 @@ import androidx.core.content.ContextCompat
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter
import com.bumptech.glide.Glide
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.example.tvmon.R
import com.example.tvmon.data.model.Content
import com.example.tvmon.data.model.Category
@@ -68,8 +70,10 @@ class ContentCardPresenter : Presenter() {
if (content.thumbnail.isNotBlank()) {
Glide.with(cardView.context)
.load(content.thumbnail)
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
.error(R.drawable.default_background)
.placeholder(R.drawable.default_background)
+ .transition(DrawableTransitionOptions.withCrossFade(200))
.centerCrop()
.into(cardView.mainImageView)
} else {
@@ -143,8 +147,10 @@ class SearchCardPresenter : Presenter() {
if (content.thumbnail.isNotBlank()) {
Glide.with(cardView.context)
.load(content.thumbnail)
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
.error(R.drawable.default_background)
.placeholder(R.drawable.default_background)
+ .transition(DrawableTransitionOptions.withCrossFade(200))
.centerCrop()
.into(cardView.mainImageView)
} else {
diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/settings/SettingsActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/settings/SettingsActivity.kt
new file mode 100644
index 0000000..e23400e
--- /dev/null
+++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/settings/SettingsActivity.kt
@@ -0,0 +1,174 @@
+package com.example.tvmon.ui.settings
+
+import android.app.AlertDialog
+import android.os.Bundle
+import android.view.WindowManager
+import android.widget.Button
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import com.example.tvmon.R
+import com.example.tvmon.util.ApkDownloader
+import com.example.tvmon.util.UpdateChecker
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+import com.example.tvmon.data.repository.CategoryCacheRepository
+
+class SettingsActivity : AppCompatActivity() {
+
+ private val categoryCacheRepository: CategoryCacheRepository by inject()
+
+ private lateinit var tvAppVersion: TextView
+ private lateinit var tvLatestVersion: TextView
+ private lateinit var btnClearCache: Button
+ private lateinit var btnCheckUpdate: Button
+ private lateinit var btnBack: Button
+
+ private var latestVersionInfo: com.example.tvmon.util.VersionInfo? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ @Suppress("DEPRECATION")
+ window.setFlags(
+ WindowManager.LayoutParams.FLAG_FULLSCREEN,
+ WindowManager.LayoutParams.FLAG_FULLSCREEN
+ )
+
+ setContentView(R.layout.activity_settings)
+
+ initViews()
+ loadSettings()
+ setupListeners()
+ }
+
+ private fun initViews() {
+ tvAppVersion = findViewById(R.id.tv_app_version)
+ tvLatestVersion = findViewById(R.id.tv_latest_version)
+ btnClearCache = findViewById(R.id.btn_clear_cache)
+ btnCheckUpdate = findViewById(R.id.btn_check_update)
+ btnBack = findViewById(R.id.btn_back)
+ }
+
+ private fun loadSettings() {
+ val versionName = UpdateChecker.getCurrentVersionName(this)
+ val versionCode = UpdateChecker.getCurrentVersionCode(this)
+ tvAppVersion.text = "v$versionName (build $versionCode)"
+
+ loadLatestVersion()
+ }
+
+ private fun loadLatestVersion() {
+ lifecycleScope.launch {
+ try {
+ latestVersionInfo = UpdateChecker.fetchLatestVersion()
+
+ if (latestVersionInfo != null) {
+ val currentVersionCode = UpdateChecker.getCurrentVersionCode(this@SettingsActivity)
+ if (latestVersionInfo!!.versionCode > currentVersionCode) {
+ tvLatestVersion.text = "최신 버전: v${latestVersionInfo!!.versionName} (업데이트 가능)"
+ tvLatestVersion.setTextColor(getColor(R.color.accent))
+ } else {
+ tvLatestVersion.text = "최신 버전: v${latestVersionInfo!!.versionName} (최신)"
+ tvLatestVersion.setTextColor(getColor(R.color.netflix_light_gray))
+ }
+ } else {
+ tvLatestVersion.text = "최신 버전: 확인 실패"
+ }
+ } catch (e: Exception) {
+ tvLatestVersion.text = "최신 버전: 확인 실패"
+ }
+ }
+ }
+
+ private fun setupListeners() {
+ btnClearCache.setOnClickListener {
+ showClearCacheDialog()
+ }
+
+ btnCheckUpdate.setOnClickListener {
+ checkForUpdate()
+ }
+
+ btnBack.setOnClickListener {
+ finish()
+ }
+ }
+
+ private fun showClearCacheDialog() {
+ AlertDialog.Builder(this)
+ .setTitle("캐시 삭제")
+ .setMessage("모든 캐시 데이터를 삭제하시겠습니까?\n\n삭제 항목:\n- 영화/에피소드 정보\n- 이미지 캐시")
+ .setPositiveButton("삭제") { _, _ ->
+ clearAllCache()
+ }
+ .setNegativeButton("취소", null)
+ .show()
+ }
+
+ private fun clearAllCache() {
+ lifecycleScope.launch {
+ try {
+ categoryCacheRepository.clearCache()
+
+ com.bumptech.glide.Glide.get(this@SettingsActivity).clearMemory()
+ com.bumptech.glide.Glide.get(this@SettingsActivity).clearDiskCache()
+
+ Toast.makeText(this@SettingsActivity, "캐시가 삭제되었습니다", Toast.LENGTH_SHORT).show()
+ } catch (e: Exception) {
+ Toast.makeText(this@SettingsActivity, "캐시 삭제 실패: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun checkForUpdate() {
+ lifecycleScope.launch {
+ try {
+ val latestVersion = UpdateChecker.fetchLatestVersion()
+
+ if (latestVersion != null) {
+ if (UpdateChecker.needsUpdate(this@SettingsActivity, latestVersion)) {
+ showUpdateDialog(latestVersion)
+ } else {
+ Toast.makeText(this@SettingsActivity, "최신 버전입니다 (v${latestVersion.versionName})", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(this@SettingsActivity, "업데이트 정보를 가져올 수 없습니다", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(this@SettingsActivity, "업데이트 확인 실패: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun showUpdateDialog(versionInfo: com.example.tvmon.util.VersionInfo) {
+ val message = buildString {
+ append("현재 버전: ${UpdateChecker.getCurrentVersionName(this@SettingsActivity)}\n")
+ append("최신 버전: ${versionInfo.versionName}\n\n")
+ append(versionInfo.updateMessage)
+ }
+
+ AlertDialog.Builder(this)
+ .setTitle("업데이트 알림")
+ .setMessage(message)
+ .setPositiveButton("업데이트") { _, _ ->
+ Toast.makeText(this, "업데이트 다운로드 시작...", Toast.LENGTH_SHORT).show()
+ lifecycleScope.launch {
+ try {
+ val apkFile = ApkDownloader.downloadApkDirect(this@SettingsActivity, versionInfo.apkUrl)
+ if (apkFile != null) {
+ ApkDownloader.installApk(this@SettingsActivity, apkFile)
+ finish()
+ } else {
+ Toast.makeText(this@SettingsActivity, "다운로드 실패", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(this@SettingsActivity, "다운로드 오류: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ .setNegativeButton("나중에", null)
+ .show()
+ }
+}
diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/util/ApkDownloader.kt b/tvmon-app/app/src/main/java/com/example/tvmon/util/ApkDownloader.kt
new file mode 100644
index 0000000..860a602
--- /dev/null
+++ b/tvmon-app/app/src/main/java/com/example/tvmon/util/ApkDownloader.kt
@@ -0,0 +1,129 @@
+package com.example.tvmon.util
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.TimeUnit
+
+object ApkDownloader {
+ private const val TAG = "ApkDownloader"
+ private const val APK_FILE_NAME = "tvmon_update.apk"
+ private const val CONNECT_TIMEOUT = 30L
+ private const val READ_TIMEOUT = 60L
+
+ private val okHttpClient by lazy {
+ OkHttpClient.Builder()
+ .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
+ .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
+ .build()
+ }
+
+ suspend fun downloadApkDirect(context: Context, apkUrl: String): File? = withContext(Dispatchers.IO) {
+ var response: Response? = null
+ var outputStream: FileOutputStream? = null
+ try {
+ android.util.Log.i(TAG, "Starting download from: $apkUrl")
+
+ val apkFile = getApkFile(context)
+ android.util.Log.i(TAG, "APK file path: ${apkFile.absolutePath}")
+
+ if (apkFile.exists()) {
+ android.util.Log.i(TAG, "Deleting existing APK file")
+ apkFile.delete()
+ }
+
+ apkFile.parentFile?.mkdirs()
+
+ val request = Request.Builder()
+ .url(apkUrl)
+ .header("User-Agent", "Mozilla/5.0 (Linux; Android TV) AppleWebKit/537.36")
+ .build()
+
+ android.util.Log.i(TAG, "Executing request...")
+
+ response = okHttpClient.newCall(request).execute()
+ android.util.Log.i(TAG, "Response received: ${response.code} ${response.message}")
+
+ if (!response.isSuccessful) {
+ android.util.Log.e(TAG, "Download failed: ${response.code} ${response.message}")
+ return@withContext null
+ }
+
+ val responseBody: ResponseBody = response.body ?: return@withContext null
+ val contentLength = responseBody.contentLength()
+
+ android.util.Log.i(TAG, "Starting file download...")
+ outputStream = FileOutputStream(apkFile)
+ val inputStream = responseBody.byteStream()
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ var totalBytesRead = 0L
+
+ while (inputStream.read(buffer).also { bytesRead = it } != -1) {
+ outputStream.write(buffer, 0, bytesRead)
+ totalBytesRead += bytesRead
+
+ if (contentLength > 0) {
+ val progress = (totalBytesRead * 100 / contentLength).toInt()
+ if (progress % 10 == 0) {
+ android.util.Log.d(TAG, "Download progress: $progress%")
+ }
+ }
+ }
+
+ outputStream.flush()
+ android.util.Log.i(TAG, "APK download completed: ${apkFile.absolutePath}, size: ${apkFile.length()}")
+
+ return@withContext apkFile
+ } catch (e: Exception) {
+ android.util.Log.e(TAG, "Download failed: ${e.message}", e)
+ return@withContext null
+ } finally {
+ outputStream?.close()
+ response?.close()
+ }
+ }
+
+ fun installApk(context: Context, apkFile: File) {
+ try {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+
+ val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ apkFile
+ )
+ } else {
+ Uri.fromFile(apkFile)
+ }
+
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ }
+
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ android.util.Log.e(TAG, "Failed to install APK: ${e.message}", e)
+ }
+ }
+
+ fun getApkFile(context: Context): File {
+ return File(context.filesDir, APK_FILE_NAME)
+ }
+
+ fun isApkDownloaded(context: Context): Boolean {
+ return getApkFile(context).exists()
+ }
+}
\ No newline at end of file
diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/util/UpdateChecker.kt b/tvmon-app/app/src/main/java/com/example/tvmon/util/UpdateChecker.kt
new file mode 100644
index 0000000..71f5b54
--- /dev/null
+++ b/tvmon-app/app/src/main/java/com/example/tvmon/util/UpdateChecker.kt
@@ -0,0 +1,96 @@
+package com.example.tvmon.util
+
+import android.content.Context
+import android.content.pm.PackageManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+
+data class VersionInfo(
+ val versionCode: Int,
+ val versionName: String,
+ val apkUrl: String,
+ val updateMessage: String,
+ val forceUpdate: Boolean
+)
+
+object UpdateChecker {
+ private const val TAG = "UpdateChecker"
+
+ private const val VERSION_JSON_URL = "https://tvmon.site/version.json"
+ private const val APK_BASE_URL = "https://tvmon.site/releases"
+
+ fun getCurrentVersionCode(context: Context): Int {
+ return try {
+ context.packageManager.getPackageInfo(context.packageName, 0).versionCode
+ } catch (e: PackageManager.NameNotFoundException) {
+ 1
+ }
+ }
+
+ fun getCurrentVersionName(context: Context): String {
+ return try {
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
+ } catch (e: PackageManager.NameNotFoundException) {
+ "1.0.0"
+ }
+ }
+
+ suspend fun fetchLatestVersion(): VersionInfo? = withContext(Dispatchers.IO) {
+ fetchLatestVersionDirect()
+ }
+
+ private fun fetchLatestVersionDirect(): VersionInfo? {
+ try {
+ android.util.Log.d(TAG, "Fetching version.json from: $VERSION_JSON_URL")
+
+ val url = java.net.URL(VERSION_JSON_URL)
+ val conn = url.openConnection() as java.net.HttpURLConnection
+ conn.requestMethod = "GET"
+ conn.connectTimeout = 15000
+ conn.readTimeout = 15000
+
+ val responseCode = conn.responseCode
+ android.util.Log.d(TAG, "Response Code: $responseCode")
+
+ if (responseCode != 200) {
+ android.util.Log.e(TAG, "HTTP Error: $responseCode")
+ return null
+ }
+
+ val jsonStr = conn.inputStream.bufferedReader().readText()
+ android.util.Log.d(TAG, "Response: $jsonStr")
+
+ val json = JSONObject(jsonStr)
+ val versionCode = json.getInt("versionCode")
+ val versionName = json.getString("versionName")
+ val updateMessage = json.optString("updateMessage", "새로운 버전이 있습니다.")
+ val forceUpdate = json.optBoolean("forceUpdate", false)
+
+ val apkUrl = if (json.has("apkUrl") && !json.isNull("apkUrl")) {
+ json.getString("apkUrl")
+ } else {
+ "$APK_BASE_URL/v$versionName/app-release.apk"
+ }
+
+ android.util.Log.d(TAG, "Version: $versionName, apkUrl: $apkUrl")
+
+ return VersionInfo(
+ versionCode = versionCode,
+ versionName = versionName,
+ apkUrl = apkUrl,
+ updateMessage = updateMessage,
+ forceUpdate = forceUpdate
+ )
+ } catch (e: Exception) {
+ android.util.Log.e(TAG, "Failed to fetch version: ${e.message}", e)
+ return null
+ }
+ }
+
+ fun needsUpdate(context: Context, latestVersion: VersionInfo): Boolean {
+ val currentVersionCode = getCurrentVersionCode(context)
+ android.util.Log.i(TAG, "Current: $currentVersionCode, Latest: ${latestVersion.versionCode}")
+ return latestVersion.versionCode > currentVersionCode
+ }
+}
\ No newline at end of file
diff --git a/tvmon-app/app/src/main/res/drawable/app_banner.xml b/tvmon-app/app/src/main/res/drawable/app_banner.xml
index acc6cef..773f24d 100644
--- a/tvmon-app/app/src/main/res/drawable/app_banner.xml
+++ b/tvmon-app/app/src/main/res/drawable/app_banner.xml
@@ -1,6 +1,66 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tvmon-app/app/src/main/res/drawable/app_banner_vector.xml b/tvmon-app/app/src/main/res/drawable/app_banner_vector.xml
index 36e223e..773f24d 100644
--- a/tvmon-app/app/src/main/res/drawable/app_banner_vector.xml
+++ b/tvmon-app/app/src/main/res/drawable/app_banner_vector.xml
@@ -1,49 +1,66 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+android:width="320dp"
+android:height="180dp"
+android:viewportWidth="320"
+android:viewportHeight="180">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tvmon-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/tvmon-app/app/src/main/res/drawable/ic_launcher_foreground.xml
index d7b81a7..ace717b 100644
--- a/tvmon-app/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/tvmon-app/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,52 +1,47 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+android:width="108dp"
+android:height="108dp"
+android:viewportWidth="108"
+android:viewportHeight="108">
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tvmon-app/app/src/main/res/layout/activity_settings.xml b/tvmon-app/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000..af8621d
--- /dev/null
+++ b/tvmon-app/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tvmon-app/app/src/main/res/mipmap-hdpi/ic_launcher.png b/tvmon-app/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db3e683
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/tvmon-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..db3e683
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-mdpi/ic_launcher.png b/tvmon-app/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..2d581d5
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/tvmon-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..2d581d5
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/tvmon-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..4c32ac5
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/tvmon-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..4c32ac5
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/tvmon-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..525c2bd
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/tvmon-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..525c2bd
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/tvmon-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..1f54384
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/tvmon-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/tvmon-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1f54384
Binary files /dev/null and b/tvmon-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/tvmon-app/app/src/main/res/values/ic_launcher_background.xml b/tvmon-app/app/src/main/res/values/ic_launcher_background.xml
index dd895bf..695a21e 100644
--- a/tvmon-app/app/src/main/res/values/ic_launcher_background.xml
+++ b/tvmon-app/app/src/main/res/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
- #1F1F1F
+ #E50914
diff --git a/tvmon-app/app/src/main/res/xml/file_paths.xml b/tvmon-app/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..9a3e2d8
--- /dev/null
+++ b/tvmon-app/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tvmon-app/version.json b/tvmon-app/version.json
new file mode 100644
index 0000000..a816f06
--- /dev/null
+++ b/tvmon-app/version.json
@@ -0,0 +1,6 @@
+{
+ "versionCode": 2,
+ "versionName": "1.0.1",
+ "apkUrl": "https://git.webpluss.net/sanjeok77/tvmon_release/releases/download/v1.0.1/app-release.apk",
+ "updateMessage": "v1.0.1 업데이트\n\n- 정렬 순서 wr_datetime DESC로 변경\n- 런처 아이콘 개선 (Netflix 스타일)\n- 웹뷰 동영상 재생 최적화\n- 설정 버튼 카테고리 하단 배치"
+}