commit 461778d763f1a2a4b5df80b280bf5c4f79e29da7 Author: personal82555 Date: Mon Jun 15 23:37:44 2026 +0800 Initial commit: Favorites - Android bookmark navigator diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb3a492 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Gradle +.gradle/ +build/ +app/build/ +local.properties + +# IDE +.idea/ +*.iml +.DS_Store + +# Android +*.apk +*.aab +*.keystore +signing.properties + +# Local state +.local/ + +# Temp files +icon_preview.html diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9e5feb5 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +46 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fbcea35 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.hermes.app" + compileSdk = 35 + + defaultConfig { + applicationId = "com.hermes.app" + minSdk = 24 + targetSdk = 35 + versionCode = 46 + versionName = "1.0.46" + } + + signingConfigs { + create("release") { + storeFile = rootProject.file("hermes.keystore") + storePassword = "hermes123" + keyAlias = "hermes" + keyPassword = "hermes123" + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("release") + } + debug { + isMinifyEnabled = false + applicationIdSuffix = ".debug" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + + implementation("androidx.webkit:webkit:1.9.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..824e986 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,23 @@ +# Keep WebView JS interface +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# Keep Hermes voice bridge class explicitly (inner classes get renamed by R8) +-keep class com.hermes.app.MainActivity$HermesVoiceBridge { *; } + +# Keep Parcelable +-keepclassmembers class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator CREATOR; +} + +# Keep Serializable +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8422c77 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/hermes/app/MainActivity.kt b/app/src/main/java/com/hermes/app/MainActivity.kt new file mode 100644 index 0000000..5808378 --- /dev/null +++ b/app/src/main/java/com/hermes/app/MainActivity.kt @@ -0,0 +1,1383 @@ +package com.hermes.app + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import android.net.http.SslError +import android.os.Build +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.text.InputType +import android.text.TextUtils +import android.util.Base64 +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.webkit.* +import android.webkit.WebView +import android.widget.* +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.google.android.material.progressindicator.LinearProgressIndicator +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +class MainActivity : AppCompatActivity() { + + companion object { + private const val PREFS_NAME = "hermes_nav" + private const val PREFS_URL_LIST_KEY = "url_list" + + private const val PREFS_WEBDAV_URL = "webdav_url" + private const val PREFS_WEBDAV_USER = "webdav_user" + private const val PREFS_WEBDAV_PASS = "webdav_pass" + private const val WEBDAV_FILE = "favorites_bookmarks.json" + } + + // ---- Navigation UI ---- + private lateinit var navPage: View + private lateinit var webPage: View + private lateinit var urlListContainer: ViewGroup + private lateinit var overflowBtn: ImageButton + private lateinit var navHint: TextView + private lateinit var headerSubtitle: TextView + + // ---- WebView UI ---- + private lateinit var webViewContainer: FrameLayout + private lateinit var progressBar: LinearProgressIndicator + + // ---- Tab Strip ---- + private lateinit var tabScroll: HorizontalScrollView + private lateinit var tabStrip: LinearLayout + private lateinit var newTabBtn: ImageButton + // ---- Data ---- + private data class Bookmark(val url: String, val category: String = "", val title: String = "") + private val bookmarks = mutableListOf() + private val prefs: SharedPreferences by lazy { getSharedPreferences(PREFS_NAME, MODE_PRIVATE) } + + // ---- Pinned bookmarks (always at top, not deletable) ---- + private val pinnedBookmarks = listOf( + Bookmark("https://flat.88531.cn", "", "热门导航") + ) + + // ---- Category collapse state ---- + private val expandedCategories = mutableSetOf() + + // ---- Tab Management ---- + private class TabInfo(val url: String, var title: String) { + var webView: WebView? = null + } + private val tabs = mutableListOf() + private var activeTabIndex = -1 + + // ---- Voice ---- + private var speechRecognizer: SpeechRecognizer? = null + + // ---- File upload ---- + private var fileUploadCallback: ValueCallback>? = null + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { _: Boolean -> /* handled by WebChromeClient */ } + + private val filePickerLauncher = registerForActivityResult(StartActivityForResult()) { result -> + val uris = if (result.resultCode == RESULT_OK && result.data != null) { + val data = result.data!! + if (data.clipData != null) { + val uriList = mutableListOf() + for (i in 0 until data.clipData!!.itemCount) { + uriList.add(data.clipData!!.getItemAt(i).uri) + } + uriList.toTypedArray() + } else { + data.data?.let { arrayOf(it) } + } + } else null + fileUploadCallback?.onReceiveValue(uris) + fileUploadCallback = null + } + + private val bookmarkFilePickerLauncher = registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + uri?.let { importBookmarkFile(it) } + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + // Find views + navPage = findViewById(R.id.navPage) + webPage = findViewById(R.id.webPage) + urlListContainer = findViewById(R.id.urlListContainer) + overflowBtn = findViewById(R.id.overflowBtn) + navHint = findViewById(R.id.navHint) + headerSubtitle = findViewById(R.id.headerSubtitle) + webViewContainer = findViewById(R.id.webViewContainer) + progressBar = findViewById(R.id.progressBar) + tabScroll = findViewById(R.id.tabScroll) + tabStrip = findViewById(R.id.tabStrip) + newTabBtn = findViewById(R.id.newTabBtn) + + // Setup + setupWebView() + setupBackHandler() + checkMicrophonePermission() + + // Nav page handlers + overflowBtn.setOnClickListener { showOverflowMenu() } + newTabBtn.setOnClickListener { showNavPage() } + + // Load saved bookmarks (if empty, user adds via paste/file/webdav) + loadBookmarks() + renderBookmarks() + } + + // ==================== Navigation ==================== + + private fun loadBookmarks() { + bookmarks.clear() + val json = prefs.getString(PREFS_URL_LIST_KEY, "[]") ?: "[]" + try { + val arr = JSONArray(json) + for (i in 0 until arr.length()) { + val el = arr.get(i) + when (el) { + is String -> bookmarks.add(Bookmark(el, "", "")) // old format: just URL + is JSONArray -> { + val url = el.optString(0, "") + val cat = el.optString(1, "") + val title = el.optString(2, "") + if (url.isNotEmpty()) bookmarks.add(Bookmark(url, cat, title)) + } + } + } + } catch (_: Exception) {} + } + + private fun saveBookmarks() { + val arr = JSONArray() + for (b in bookmarks) { + arr.put(JSONArray().apply { + put(b.url) + put(b.category) + put(b.title) + }) + } + prefs.edit().putString(PREFS_URL_LIST_KEY, arr.toString()).apply() + } + + private fun renderBookmarks() { + urlListContainer.removeAllViews() + + // Pinned bookmarks (always at top) + for (b in pinnedBookmarks) { + urlListContainer.addView(createBookmarkItem(b, pinned = true)) + } + + if (pinnedBookmarks.isNotEmpty() && bookmarks.isNotEmpty()) { + // Divider + urlListContainer.addView(TextView(this).apply { + text = "── 书签 ──" + textSize = 12f + setTextColor(Color.parseColor("#CCCCCC")) + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setPadding(0, 16, 0, 8) + }) + } + + if (bookmarks.isEmpty()) { + // Show guide/onboarding when no user bookmarks + urlListContainer.addView(TextView(this).apply { + text = "暂无书签" + textSize = 18f + setTextColor(Color.parseColor("#999999")) + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setPadding(0, 48, 0, 8) + }) + urlListContainer.addView(TextView(this).apply { + text = "点击右上角 ⋮ 菜单添加" + textSize = 14f + setTextColor(Color.parseColor("#BBBBBB")) + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setPadding(0, 0, 0, 4) + }) + urlListContainer.addView(TextView(this).apply { + text = "支持粘贴导入 · 文件导入 · WebDAV 同步" + textSize = 12f + setTextColor(Color.parseColor("#CCCCCC")) + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setPadding(0, 0, 0, 32) + }) + updateNavHint() + return + } + + // Group by two-level category (delimiter: "/") + // e.g. "科技/AI工具" → top="科技", sub="AI工具" + val topGrouped = mutableMapOf>() + for (b in bookmarks) { + val top = if (b.category.contains("/")) b.category.substringBefore("/") else b.category + topGrouped.getOrPut(top) { mutableListOf() }.add(b) + } + + val topKeys = topGrouped.keys.sorted() + for (topCat in topKeys) { + val items = topGrouped[topCat]!! + + urlListContainer.addView(createCategoryHeader(topCat)) + if (topCat in expandedCategories) { + // Group items by sub-category within this top category + val subGrouped = items.groupBy { b -> + if (b.category.contains("/")) b.category.substringAfter("/") else "" + } + val subKeys = subGrouped.keys.sorted() + for (subCat in subKeys) { + val subItems = subGrouped[subCat]!! + if (subCat.isNotEmpty()) { + // Sub-category label + urlListContainer.addView(TextView(this).apply { + text = " ▸ $subCat" + textSize = 13f + setTextColor(Color.parseColor("#888888")) + setTypeface(null, android.graphics.Typeface.ITALIC) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setPadding(24, 8, 0, 4) + }) + } + for (b in subItems) { + urlListContainer.addView(createBookmarkItem(b, pinned = false)) + } + } + } + } + + // Update bottom hint if tabs are open + updateNavHint() + } + + private fun updateNavHint() { + navHint.text = if (tabs.isNotEmpty()) { + "${tabs.size} tabs open · tap a bookmark to add more" + } else { + "Paste URLs · Import HTML/Bookmarks · WebDAV Sync · Tap to open" + } + headerSubtitle.text = if (tabs.isNotEmpty()) { + "${tabs.size} tabs · tap to add more" + } else { + "Bookmark Navigator" + } + } + + private fun createCategoryHeader(category: String): View { + val isExpanded = category in expandedCategories + val arrow = if (isExpanded) "\u25BC" else "\u25B6" // ▼ or ▶ + val count = bookmarks.count { b -> + val top = if (b.category.contains("/")) b.category.substringBefore("/") else b.category + top == category + } + + val header = LinearLayout(this).apply { + layoutParams = ViewGroup.MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { setMargins(0, 10, 0, 2) } + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(14, 10, 12, 10) + setBackgroundResource(R.drawable.category_header_bg) + isClickable = true + isFocusable = true + setOnClickListener { + if (category in expandedCategories) { + expandedCategories.remove(category) + } else { + expandedCategories.add(category) + } + renderBookmarks() + } + } + + header.addView(TextView(this).apply { + text = arrow + textSize = 12f + setTextColor(Color.parseColor("#1565C0")) + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { marginEnd = 8 } + }) + + header.addView(TextView(this).apply { + text = category + textSize = 14f + setTextColor(Color.parseColor("#1565C0")) + setTypeface(null, android.graphics.Typeface.BOLD) + layoutParams = LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + ) + }) + + header.addView(TextView(this).apply { + text = count.toString() + textSize = 11f + setTextColor(Color.parseColor("#1565C0")) + setBackgroundResource(R.drawable.url_item_empty_bg) + setPadding(8, 2, 8, 2) + gravity = Gravity.CENTER + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { marginEnd = 6 } + }) + + if (!isExpanded) { + header.addView(TextView(this).apply { + text = "展开" + textSize = 11f + setTextColor(Color.parseColor("#999999")) + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + }) + } + + return header + } + + private fun createBookmarkItem(b: Bookmark, pinned: Boolean = false): View { + val item = LinearLayout(this).apply { + layoutParams = ViewGroup.MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { setMargins(0, 0, 0, 10) } + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(16, 14, 12, 14) + setBackgroundResource(R.drawable.url_item_bg) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + elevation = 2f + } + setOnClickListener { openUrl(b.url, b.title) } + isClickable = true + isFocusable = true + } + + // Globe icon + item.addView(ImageView(this).apply { + layoutParams = LinearLayout.LayoutParams(20, 20).apply { marginEnd = 12 } + setImageResource(R.drawable.ic_globe) + scaleType = ImageView.ScaleType.FIT_CENTER + colorFilter = android.graphics.PorterDuffColorFilter( + Color.parseColor("#78909C"), android.graphics.PorterDuff.Mode.SRC_IN + ) + }) + + // URL text area with category badge + val textLayout = LinearLayout(this).apply { + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + orientation = LinearLayout.VERTICAL + } + + // Title row: display name + category badge + val titleRow = LinearLayout(this).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + val displayTitle = if (b.title.isNotEmpty()) b.title else extractDisplayName(b.url) + titleRow.addView(TextView(this).apply { + text = displayTitle + textSize = 15f + setTextColor(Color.parseColor("#1A1A1A")) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + }) + + if (b.category.isNotEmpty()) { + titleRow.addView(TextView(this).apply { + text = b.category + textSize = 10f + setTextColor(Color.parseColor("#1565C0")) + setBackgroundResource(R.drawable.url_item_empty_bg) + setPadding(6, 2, 6, 2) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { marginStart = 8 } + }) + } + + textLayout.addView(titleRow) + + textLayout.addView(TextView(this).apply { + text = b.url + textSize = 12f + setTextColor(Color.parseColor("#9E9E9E")) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.MIDDLE + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { topMargin = 3 } + }) + + item.addView(textLayout) + + // Delete button (not shown for pinned bookmarks) + if (!pinned) { + val deleteBtn = ImageButton(this).apply { + layoutParams = LinearLayout.LayoutParams(36, 36).apply { marginStart = 8 } + scaleType = ImageView.ScaleType.CENTER + setImageResource(android.R.drawable.ic_menu_close_clear_cancel) + setBackgroundColor(Color.TRANSPARENT) + colorFilter = android.graphics.PorterDuffColorFilter( + Color.parseColor("#BDBDBD"), android.graphics.PorterDuff.Mode.SRC_IN + ) + setOnClickListener { + bookmarks.removeAll { it.url == b.url } + saveBookmarks() + renderBookmarks() + } + } + item.addView(deleteBtn) + } + + return item + } + private fun extractDisplayName(url: String): String { + return try { + val uri = Uri.parse(url) + uri.host ?: url + } catch (_: Exception) { + if (url.length > 40) url.take(37) + "..." else url + } + } + + // ==================== Tab Management ==================== + + private fun createTabWebView(): WebView { + return WebView(this).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + WebView.setWebContentsDebuggingEnabled(true) + } + + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false + cacheMode = WebSettings.LOAD_DEFAULT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + safeBrowsingEnabled = true + } + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + mediaPlaybackRequiresUserGesture = false + } + + addJavascriptInterface(HermesVoiceBridge(), "HermesVoice") + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + if (view == tabs.getOrNull(activeTabIndex)?.webView) { + progressBar.visibility = View.VISIBLE + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + if (view == tabs.getOrNull(activeTabIndex)?.webView) { + progressBar.visibility = View.GONE + } + // Update tab title + val idx = tabs.indexOfFirst { it.webView == view } + if (idx >= 0 && url != null) { + val title = view?.title?.takeIf { it.isNotEmpty() } ?: extractDisplayName(url) + tabs[idx].title = title + if (idx == activeTabIndex) { + renderTabStrip() + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + view?.evaluateJavascript(speechPolyfillJS, null) + } + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean = false + + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + handler?.proceed() + } + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + if (view == tabs.getOrNull(activeTabIndex)?.webView) { + progressBar.progress = newProgress + if (newProgress >= 100) progressBar.visibility = View.GONE + } + } + + override fun onPermissionRequest(request: PermissionRequest?) { + request ?: return + val granted = request.resources.any { + it == PermissionRequest.RESOURCE_AUDIO_CAPTURE || + it == PermissionRequest.RESOURCE_VIDEO_CAPTURE + } + if (granted) request.grant(request.resources) else request.deny() + } + + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams? + ): Boolean { + fileUploadCallback?.onReceiveValue(null) + fileUploadCallback = filePathCallback + val intent = fileChooserParams?.createIntent() ?: return false + filePickerLauncher.launch(intent) + return true + } + } + + visibility = View.GONE + } + } + + private fun openUrl(url: String, title: String = "") { + val tabTitle = title.takeIf { it.isNotEmpty() } ?: extractDisplayName(url) + val tab = TabInfo(url, tabTitle) + tab.webView = createTabWebView().also { wv -> + webViewContainer.addView(wv) + wv.loadUrl(url) + } + tabs.add(tab) + activeTabIndex = tabs.size - 1 + + // Show active, hide others + for (t in tabs) { + t.webView?.visibility = if (t == tab) View.VISIBLE else View.GONE + } + + renderTabStrip() + scrollToCurrentTab() + + navPage.visibility = View.GONE + webPage.visibility = View.VISIBLE + } + + private fun switchTab(index: Int) { + if (index < 0 || index >= tabs.size || index == activeTabIndex) return + val prevIdx = activeTabIndex + activeTabIndex = index + + tabs.getOrNull(prevIdx)?.webView?.visibility = View.GONE + tabs[index].webView?.visibility = View.VISIBLE + + renderTabStrip() + scrollToCurrentTab() + } + + private fun closeTab(index: Int) { + if (index < 0 || index >= tabs.size) return + val tab = tabs[index] + tab.webView?.let { wv -> + webViewContainer.removeView(wv) + wv.destroy() + } + tabs.removeAt(index) + if (tabs.isEmpty()) { + activeTabIndex = -1 + showNavPage() + return + } + // Adjust active index + activeTabIndex = when { + index <= activeTabIndex -> (activeTabIndex - 1).coerceAtLeast(0) + else -> activeTabIndex + } + tabs[activeTabIndex].webView?.visibility = View.VISIBLE + renderTabStrip() + scrollToCurrentTab() + } + + private fun closeCurrentTab() { + closeTab(activeTabIndex) + } + + private fun renderTabStrip() { + tabStrip.removeAllViews() + for (i in tabs.indices) { + val tab = tabs[i] + val isActive = i == activeTabIndex + + val tabView = LinearLayout(this).apply { + layoutParams = LinearLayout.LayoutParams( + 100.dpToPx(), + 32.dpToPx() + ).apply { setMargins(3, 6, 3, 6) } + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(8, 0, 4, 0) + if (isActive) { + setBackgroundColor(Color.WHITE) + elevation = 2f + } else { + setBackgroundColor(Color.parseColor("#E8E8E8")) + elevation = 0f + } + isClickable = true + isFocusable = true + setOnClickListener { switchTab(i) } + } + + // Title + tabView.addView(TextView(this).apply { + text = tab.title + textSize = 12f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setTextColor(if (isActive) Color.parseColor("#1565C0") else Color.parseColor("#777777")) + setTypeface(null, if (isActive) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL) + layoutParams = LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + ) + }) + + // Close button (small ×) + tabView.addView(ImageButton(this).apply { + layoutParams = LinearLayout.LayoutParams(20, 20) + scaleType = ImageView.ScaleType.CENTER + setImageResource(android.R.drawable.ic_menu_close_clear_cancel) + setBackgroundColor(Color.TRANSPARENT) + setOnClickListener { closeTab(i) } + contentDescription = "关闭标签" + }) + + tabStrip.addView(tabView) + } + } + + private fun scrollToCurrentTab() { + tabScroll.post { + val child = tabStrip.getChildAt(activeTabIndex) ?: return@post + val target = if (activeTabIndex > 0) child.left - 20 else 0 + tabScroll.smoothScrollTo(target, 0) + } + } + + private fun Int.dpToPx(): Int { + return (this * resources.displayMetrics.density).toInt() + } + + // ==================== Page Navigation ==================== + + private fun showNavPage() { + webPage.visibility = View.GONE + navPage.visibility = View.VISIBLE + updateNavHint() + } + + // ==================== Dialogs ==================== + + private fun showImportDialog() { + val input = EditText(this).apply { + hint = "每行一个地址:\nhttps://hermes2.88531.cn\nhttps://hermes.88531.cn" + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE + minLines = 6 + isVerticalScrollBarEnabled = true + } + input.setPadding(24, 16, 24, 16) + + val container = FrameLayout(this).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setPadding(40, 16, 40, 8) + addView(input) + } + + AlertDialog.Builder(this) + .setTitle("批量导入地址") + .setView(container) + .setPositiveButton("添加") { _, _ -> + val text = input.text.toString().trim() + if (text.isNotEmpty()) { + val lines = text.lines().map { it.trim() }.filter { it.isNotEmpty() } + var added = 0 + for (line in lines) { + var url = line + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://$url" + } + if (bookmarks.none { it.url == url }) { + bookmarks.add(Bookmark(url, "")) + added++ + } + } + if (added > 0) { + saveBookmarks() + renderBookmarks() + Toast.makeText(this, "已添加 $added 个地址", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "没有新地址需要添加", Toast.LENGTH_SHORT).show() + } + } + } + .setNegativeButton("取消", null) + .show() + } + + private fun importFromFile() { + bookmarkFilePickerLauncher.launch("text/html") + } + + private fun importBookmarkFile(uri: Uri) { + try { + val content = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: return + val imported = parseBookmarkHtml(content) + + if (imported.isEmpty()) { + Toast.makeText(this, "没有找到书签链接", Toast.LENGTH_SHORT).show() + return + } + + var added = 0 + for (b in imported) { + if (bookmarks.none { it.url == b.url }) { + bookmarks.add(b) + added++ + } + } + + if (added > 0) { + saveBookmarks() + renderBookmarks() + Toast.makeText(this, "已导入 $added 个书签", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "书签都已存在,无新增", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(this, "导入失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + private fun showClearAllDialog() { + val count = bookmarks.size + if (count == 0) { + Toast.makeText(this, "没有书签需要清空", Toast.LENGTH_SHORT).show() + return + } + AlertDialog.Builder(this) + .setTitle("清空所有书签") + .setMessage("确定要删除全部 $count 个书签吗?此操作不可撤销。") + .setPositiveButton("确定清空") { _, _ -> + bookmarks.clear() + saveBookmarks() + renderBookmarks() + Toast.makeText(this, "已清空所有书签", Toast.LENGTH_SHORT).show() + } + .setNegativeButton("取消", null) + .show() + } + + /** + * Parse Netscape bookmark HTML (Edge/Chrome export format), + * preserving folder hierarchy as category names. + */ + private fun parseBookmarkHtml(html: String): List { + val result = mutableListOf() + val categoryStack = mutableListOf("") + var currentCategory = "" + + val h3Pattern = Regex("""]*>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + val aPattern = Regex("""]*HREF="([^"]+)"[^>]*>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + val dlStartPattern = Regex("""]*)?>""", RegexOption.IGNORE_CASE) + val dlEndPattern = Regex("""""", RegexOption.IGNORE_CASE) + + var pos = 0 + val len = html.length + + while (pos < len) { + val h3 = h3Pattern.find(html, pos) + val a = aPattern.find(html, pos) + val dlStart = dlStartPattern.find(html, pos) + val dlEnd = dlEndPattern.find(html, pos) + + val candidates = listOfNotNull( + h3?.let { Triple("H3", it.value, it.range.last) }, + a?.let { Triple("A", it.value, it.range.last) }, + dlStart?.let { Triple("DL_START", it.value, it.range.last) }, + dlEnd?.let { Triple("DL_END", it.value, it.range.last) } + ).map { (type, _, end) -> + val start = when (type) { + "H3" -> h3!!.range.first + "A" -> a!!.range.first + "DL_START" -> dlStart!!.range.first + "DL_END" -> dlEnd!!.range.first + else -> Int.MAX_VALUE + } + Quadruple(start, type, end) + } + + if (candidates.isEmpty()) break + + val best = candidates.minBy { it.start } + val type = best.type + val endPos = best.end + + when (type) { + "H3" -> { + val folderName = h3!!.groupValues[1].trim() + // Build full category path from stack hierarchy + val parentPath = categoryStack.filter { it.isNotEmpty() }.joinToString("/") + val fullPath = if (parentPath.isNotEmpty()) "$parentPath/$folderName" else folderName + currentCategory = fullPath + if (currentCategory.length > 40) currentCategory = currentCategory.take(37) + "..." + } + "A" -> { + val url = a!!.groupValues[1].trim() + val name = a.groupValues[2].trim() + if (url.startsWith("http://") || url.startsWith("https://")) { + result.add(Bookmark(url, currentCategory, name)) + } + } + "DL_START" -> { + categoryStack.add(currentCategory) + } + "DL_END" -> { + if (categoryStack.size > 1) { + categoryStack.removeAt(categoryStack.lastIndex) + currentCategory = categoryStack.last() + } + } + } + + pos = endPos + 1 + } + + return result + } + + private data class Quadruple(val start: Int, val type: String, val end: Int) + + // ==================== Overflow Menu ==================== + + private fun showOverflowMenu() { + val popup = PopupMenu(this, overflowBtn) + popup.menu.add(0, 1, 0, "粘贴导入").setIcon(android.R.drawable.ic_menu_edit) + popup.menu.add(0, 2, 0, "文件导入").setIcon(android.R.drawable.ic_menu_gallery) + popup.menu.add(0, 3, 0, "WebDAV 导出").setIcon(android.R.drawable.ic_menu_save) + popup.menu.add(0, 4, 0, "WebDAV 导入").setIcon(android.R.drawable.ic_menu_rotate) + popup.menu.add(0, 5, 0, "WebDAV 配置").setIcon(android.R.drawable.ic_menu_manage) + popup.menu.add(0, 6, 0, "清空所有").setIcon(android.R.drawable.ic_menu_delete) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + 1 -> showImportDialog() + 2 -> importFromFile() + 3 -> webdavExport() + 4 -> webdavImport() + 5 -> showWebdavConfigDialog() + 6 -> showClearAllDialog() + } + true + } + popup.show() + } + + // ==================== WebDAV ==================== + + private fun getWebdavPrefs(): Triple { + val url = prefs.getString(PREFS_WEBDAV_URL, "") ?: "" + val user = prefs.getString(PREFS_WEBDAV_USER, "") ?: "" + val pass = prefs.getString(PREFS_WEBDAV_PASS, "") ?: "" + return Triple(url, user, pass) + } + + private fun showWebdavConfigDialog() { + val (savedUrl, savedUser, savedPass) = getWebdavPrefs() + + val urlInput = EditText(this).apply { + hint = "WebDAV 服务器地址" + setText(savedUrl) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI + setPadding(24, 16, 24, 16) + } + val userInput = EditText(this).apply { + hint = "用户名" + setText(savedUser) + inputType = InputType.TYPE_CLASS_TEXT + setPadding(24, 16, 24, 16) + } + val passInput = EditText(this).apply { + hint = "密码" + setText(savedPass) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + setPadding(24, 16, 24, 16) + } + + val layout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(40, 16, 40, 8) + addView(urlInput) + addView(userInput) + addView(passInput) + } + + AlertDialog.Builder(this) + .setTitle("WebDAV 配置") + .setView(layout) + .setPositiveButton("保存") { _, _ -> + prefs.edit() + .putString(PREFS_WEBDAV_URL, urlInput.text.toString().trim()) + .putString(PREFS_WEBDAV_USER, userInput.text.toString().trim()) + .putString(PREFS_WEBDAV_PASS, passInput.text.toString().trim()) + .apply() + Toast.makeText(this, "已保存 WebDAV 配置", Toast.LENGTH_SHORT).show() + } + .setNegativeButton("取消", null) + .show() + } + + private fun webdavExport() { + val (url, user, pass) = getWebdavPrefs() + if (url.isEmpty()) { + Toast.makeText(this, "请先配置 WebDAV", Toast.LENGTH_SHORT).show() + return + } + Toast.makeText(this, "正在导出到 WebDAV...", Toast.LENGTH_SHORT).show() + Thread { + try { + val json = JSONArray() + for (b in bookmarks) { + json.put(JSONObject().apply { + put("url", b.url) + put("category", b.category) + }) + } + val body = json.toString(2).toByteArray(Charsets.UTF_8) + + val conn = URL(url.trimEnd('/') + "/" + WEBDAV_FILE).openConnection() as HttpURLConnection + conn.doOutput = true + conn.requestMethod = "PUT" + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8") + if (user.isNotEmpty()) { + val auth = Base64.encodeToString("$user:$pass".toByteArray(), Base64.NO_WRAP) + conn.setRequestProperty("Authorization", "Basic $auth") + } + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + conn.outputStream.use { it.write(body) } + + val code = conn.responseCode + runOnUiThread { + if (code in 200..299) { + Toast.makeText(this, "已导出 ${bookmarks.size} 个书签到 WebDAV", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "WebDAV 导出失败: HTTP $code", Toast.LENGTH_SHORT).show() + } + } + } catch (e: Exception) { + runOnUiThread { + Toast.makeText(this, "WebDAV 导出失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + }.start() + } + + private fun webdavImport() { + val (url, user, pass) = getWebdavPrefs() + if (url.isEmpty()) { + Toast.makeText(this, "请先配置 WebDAV", Toast.LENGTH_SHORT).show() + return + } + Toast.makeText(this, "正在从 WebDAV 导入...", Toast.LENGTH_SHORT).show() + Thread { + try { + val conn = URL(url.trimEnd('/') + "/" + WEBDAV_FILE).openConnection() as HttpURLConnection + conn.requestMethod = "GET" + if (user.isNotEmpty()) { + val auth = Base64.encodeToString("$user:$pass".toByteArray(), Base64.NO_WRAP) + conn.setRequestProperty("Authorization", "Basic $auth") + } + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + + val code = conn.responseCode + if (code != 200) { + runOnUiThread { + Toast.makeText(this, "WebDAV 导入失败: HTTP $code", Toast.LENGTH_SHORT).show() + } + return@Thread + } + + val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val arr = JSONArray(body) + var added = 0 + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + val bUrl = obj.getString("url") + val bCat = obj.optString("category", "") + if (bookmarks.none { it.url == bUrl }) { + bookmarks.add(Bookmark(bUrl, bCat)) + added++ + } + } + runOnUiThread { + if (added > 0) { + saveBookmarks() + renderBookmarks() + Toast.makeText(this, "已从 WebDAV 导入 $added 个书签", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "没有新书签需要导入", Toast.LENGTH_SHORT).show() + } + } + } catch (e: Exception) { + runOnUiThread { + Toast.makeText(this, "WebDAV 导入失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + }.start() + } + + // ==================== Microphone Permission ==================== + + private fun checkMicrophonePermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + when { + ContextCompat.checkSelfPermission( + this, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED -> { } + else -> { + requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + } + + // ==================== Native Speech-to-Text Bridge ==================== + + @SuppressLint("SetJavaScriptEnabled") + inner class HermesVoiceBridge { + @JavascriptInterface + fun startListening(language: String) { + runOnUiThread { startSpeechRecognition(language) } + } + + @JavascriptInterface + fun stopListening() { + runOnUiThread { speechRecognizer?.stopListening() } + } + + @JavascriptInterface + fun cancel() { + runOnUiThread { + speechRecognizer?.cancel() + speechRecognizer?.destroy() + speechRecognizer = null + } + } + + @JavascriptInterface + fun isAvailable(): Boolean { + return SpeechRecognizer.isRecognitionAvailable(this@MainActivity) + } + } + + private fun startSpeechRecognition(language: String) { + speechRecognizer?.cancel() + speechRecognizer?.destroy() + + val recognizer = SpeechRecognizer.createSpeechRecognizer(this) + speechRecognizer = recognizer + + recognizer.setRecognitionListener(object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + evaluateJS(READY_FOR_SPEECH_JS) + } + override fun onBeginningOfSpeech() {} + override fun onRmsChanged(rmsdB: Float) {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() { evaluateJS(ON_END_JS) } + + override fun onError(error: Int) { + val errorMsg = when (error) { + SpeechRecognizer.ERROR_AUDIO -> "audio" + SpeechRecognizer.ERROR_CLIENT -> "client" + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "permission" + SpeechRecognizer.ERROR_NETWORK -> "network" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "network_timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "no_match" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "busy" + SpeechRecognizer.ERROR_SERVER -> "server" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "speech_timeout" + SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "too_many_requests" + else -> "unknown" + } + evaluateJS("window.__hermesVoiceBridge?.onError?.('$errorMsg')") + } + + override fun onResults(results: Bundle?) { + val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + if (!matches.isNullOrEmpty()) { + val transcript = matches[0] + val jsonArray = JSONArray() + for (m in matches) jsonArray.put(m) + val scores = results?.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES) + val scoresArray = JSONArray() + if (scores != null) { + for (s in scores) scoresArray.put(s.toDouble()) + } + evaluateJS(""" + window.__hermesVoiceBridge?.onResult?.({ + transcript: ${JSONObject.quote(transcript)}, + matches: $jsonArray, + confidence: $scoresArray + }) + """.trimIndent()) + } else { + evaluateJS("window.__hermesVoiceBridge?.onError?.('no_match')") + } + } + + override fun onPartialResults(partialResults: Bundle?) { + val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + if (!matches.isNullOrEmpty()) { + val partial = matches[0] + val escaped = partial.replace("\\", "\\\\\\\\") + .replace("'", "\\\\'") + .replace("\n", "\\\\n") + .replace("\r", "\\\\r") + evaluateJS("window.__hermesVoiceBridge?.onPartialResult?.('$escaped')") + } + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + }) + + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN") + putExtra("android.speech.extra.EXTRA_LANGUAGE_PREFERENCE", "zh-CN") + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra("android.speech.extra.SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS", 1000L) + putExtra("android.speech.extra.SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS", 500L) + putExtra("android.speech.extra.SPEECH_INPUT_MINIMUM_LENGTH_MILLIS", 300L) + putExtra("android.speech.extra.EXTRA_PREFER_OFFLINE", true) + } + + recognizer.startListening(intent) + } + + private fun evaluateJS(script: String) { + runOnUiThread { + tabs.getOrNull(activeTabIndex)?.webView?.evaluateJavascript(script, null) + } + } + + private val READY_FOR_SPEECH_JS = "window.__hermesVoiceBridge?.onStart?.()" + private val ON_END_JS = "window.__hermesVoiceBridge?.onEnd?.()" + + private val speechPolyfillJS = """ +(function() { + if (window.__hermesVoiceBridgeInstalled) return; + window.__hermesVoiceBridgeInstalled = true; + + var bridge = { + _recognition: null, _isListening: false, + + onStart: function() { + this._isListening = true; + if (this._recognition && this._recognition.onstart) { + try { this._recognition.onstart.call(this._recognition); } catch(e) {} + } + }, + + onEnd: function() { + this._isListening = false; + if (this._recognition && this._recognition.onend) { + try { this._recognition.onend.call(this._recognition); } catch(e) {} + } + }, + + onResult: function(result) { + if (!this._recognition) return; + var confidence = (result.confidence && result.confidence.length > 0) ? result.confidence[0] : 0.9; + var speechEvent = { + resultIndex: 0, + results: [[{ transcript: result.transcript, confidence: confidence }]], + interpretation: null, emma: null + }; + speechEvent.results[0].isFinal = true; + if (this._recognition.onresult) { + try { this._recognition.onresult.call(this._recognition, speechEvent); } catch(e) {} + } + }, + + onPartialResult: function(transcript) { + if (!this._recognition) return; + if (this._recognition.onresult) { + try { + this._recognition.onresult.call(this._recognition, { + resultIndex: 0, + results: [[{ transcript: transcript, confidence: 0.5 }]], + interpretation: null, emma: null + }); + } catch(e) {} + } + }, + + onError: function(error) { + this._isListening = false; + if (this._recognition && this._recognition.onerror) { + try { this._recognition.onerror.call(this._recognition, { error: error, message: error }); } catch(e) {} + } + } + }; + + window.__hermesVoiceBridge = bridge; + + var NativeSpeechRecognition = function() { + this.grammars = null; this.lang = ''; + this.continuous = false; this.interimResults = true; + this.maxAlternatives = 1; this.serviceURI = ''; + this.onstart = null; this.onend = null; this.onresult = null; + this.onerror = null; this.onnomatch = null; + this.onaudiostart = null; this.onaudioend = null; + this.onsoundstart = null; this.onsoundend = null; + this.onspeechstart = null; this.onspeechend = null; + }; + + NativeSpeechRecognition.prototype.start = function() { + bridge._recognition = this; + try { HermesVoice.startListening(this.lang || ''); } catch(e) { + console.error('HermesVoice bridge error:', e); + } + }; + + NativeSpeechRecognition.prototype.stop = function() { + bridge._recognition = this; + try { HermesVoice.stopListening(); } catch(e) {} + }; + + NativeSpeechRecognition.prototype.abort = function() { + bridge._recognition = this; + try { HermesVoice.cancel(); } catch(e) {} + }; + + window.SpeechRecognition = NativeSpeechRecognition; + window.webkitSpeechRecognition = NativeSpeechRecognition; + + console.log('[HermesVoice] SpeechRecognition polyfill installed'); +})(); +""".trimIndent() + + // ==================== WebView Setup ==================== + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + WebView.setWebContentsDebuggingEnabled(true) + } + } + + // ==================== Back Button ==================== + + private var lastExitTime = 0L // nav page double-tap exit + private var lastNavTime = 0L // web page double-tap back to nav + + private fun setupBackHandler() { + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (webPage.visibility == View.VISIBLE) { + // Web page: single press = browser back, double press = back to nav page + val wv = tabs.getOrNull(activeTabIndex)?.webView + val now = System.currentTimeMillis() + if (now - lastNavTime < 2000) { + lastNavTime = 0 + showNavPage() + } else if (wv != null && wv.canGoBack()) { + lastNavTime = now + wv.goBack() + } else { + lastNavTime = now + Toast.makeText(this@MainActivity, "再按一次返回首页", Toast.LENGTH_SHORT).show() + } + } else { + // Nav page: single press = start exit timer, double press = exit app + val now = System.currentTimeMillis() + if (now - lastExitTime < 2000) { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } else { + lastExitTime = now + Toast.makeText(this@MainActivity, "再按一次退出应用", Toast.LENGTH_SHORT).show() + } + } + } + }) + } + + // ==================== Lifecycle ==================== + + override fun onDestroy() { + super.onDestroy() + } +} diff --git a/app/src/main/res/drawable/back_btn_bg.xml b/app/src/main/res/drawable/back_btn_bg.xml new file mode 100644 index 0000000..27176db --- /dev/null +++ b/app/src/main/res/drawable/back_btn_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/category_header_bg.xml b/app/src/main/res/drawable/category_header_bg.xml new file mode 100644 index 0000000..ef538a1 --- /dev/null +++ b/app/src/main/res/drawable/category_header_bg.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_back_arrow.xml b/app/src/main/res/drawable/ic_back_arrow.xml new file mode 100644 index 0000000..4e139d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_back_arrow.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_globe.xml b/app/src/main/res/drawable/ic_globe.xml new file mode 100644 index 0000000..5e6614b --- /dev/null +++ b/app/src/main/res/drawable/ic_globe.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b303774 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5aa2049 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_overflow.xml b/app/src/main/res/drawable/ic_overflow.xml new file mode 100644 index 0000000..789daba --- /dev/null +++ b/app/src/main/res/drawable/ic_overflow.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/spinner_bg.xml b/app/src/main/res/drawable/spinner_bg.xml new file mode 100644 index 0000000..40dadb9 --- /dev/null +++ b/app/src/main/res/drawable/spinner_bg.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/url_item_bg.xml b/app/src/main/res/drawable/url_item_bg.xml new file mode 100644 index 0000000..8883a13 --- /dev/null +++ b/app/src/main/res/drawable/url_item_bg.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/url_item_empty_bg.xml b/app/src/main/res/drawable/url_item_empty_bg.xml new file mode 100644 index 0000000..1660024 --- /dev/null +++ b/app/src/main/res/drawable/url_item_empty_bg.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..df00a59 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a83a24e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..f6ff142 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..20055f6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..a9a87ed Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..e73d7ec Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ba7032c --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + #1565C0 + #0D47A1 + #42A5F5 + #FAFAFA + #FFFFFF + #000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d485f4f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Favorites + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..00ec419 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + diff --git a/build-apk.sh b/build-apk.sh new file mode 100755 index 0000000..1e8d346 --- /dev/null +++ b/build-apk.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e + +cd "$(dirname "$0")" +PROJECT_DIR="$(pwd)" +APP_DIR="$PROJECT_DIR/app" +OUTPUT_DIR="/vol2/1000/HD2/APK" + +# 读取版本号 +VERSION=$(cat VERSION 2>/dev/null || echo 0) +NEXT=$((VERSION + 1)) + +echo "========== Favorites APK Builder ==========" +echo " 当前版本: v$VERSION → v$NEXT" +echo " 目标目录: $OUTPUT_DIR" +echo "" + +export ANDROID_HOME=/opt/android-sdk +export ANDROID_SDK_ROOT=/opt/android-sdk +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 + +# 更新 build.gradle.kts 中的 versionCode 和 versionName +sed -i "s/versionCode = [0-9]*/versionCode = $NEXT/" "$APP_DIR/build.gradle.kts" +sed -i "s/versionName = \"[0-9.]*\"/versionName = \"1.0.$NEXT\"/" "$APP_DIR/build.gradle.kts" + +# 构建 +echo ">>> 开始构建..." +/opt/gradle-8.5/bin/gradle --no-daemon -p "$PROJECT_DIR" :app:assembleRelease 2>&1 +echo "" +echo ">>> 构建完成!" + +# 复制到目标目录 +APK_SRC="$PROJECT_DIR/app/build/outputs/apk/release/app-release.apk" +APK_DST="$OUTPUT_DIR/favorites_v${NEXT}.apk" +mkdir -p "$OUTPUT_DIR" +cp "$APK_SRC" "$APK_DST" + +# 写入新版本号 +echo "$NEXT" > VERSION + +echo "" +echo "========== 成功 ==========" +echo " APK: $APK_DST" +ls -lh "$APK_DST" +echo "===========================" diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e1e81fa --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8679d5b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +android.suppressUnsupportedCompileSdk=35 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..9e1f4cb --- /dev/null +++ b/gradlew @@ -0,0 +1,130 @@ +#!/bin/sh + +# +# Gradle start up script for POSIX generated by Gradle. +# + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 ; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in + '' | soft) :;; + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and backslashes, so put them in double quotes +# to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-528teleco]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" \ + $DEFAULT_JVM_OPTS \ + $JAVA_OPTS \ + $GRADLE_OPTS \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..118a595 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "HermesApp" +include(":app")