From 086370dfe25db3ce414c0e67ce8b1bb3e27ec039 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Wed, 14 Oct 2026 08:53:41 +0000 Subject: [PATCH 01/20] fix(ui): real Chrome phone layout, working find-in-page/translate, theme + perf hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full audit in docs/AUDIT-2026-10-14.md (AGENTS.md incident log extended with #20-#27). Phone (Chrome-parity): * activity_main.xml: replace the dead 0dp llBottomPanel with a real Chrome bottom toolbar (back / home / new tab / tabs badge / menu); page container now sits layout_above it. * One place decides layout: MainActivityUI.isCompactChromeLayout() (not TV && smallestWidth < 600dp). Phone = address pill on top + toolbar on bottom; TV/tablet keep the full D-pad header + top tab bar. * requestHeaderFocus()/getHeaderFocusableViews() only ever touch visible controls (focus used to be dropped on a GONE button, freezing D-pad & keyboard navigation). * toggleMenu()/hideMenuOverlay(hideBottomButtons) were no-ops; the "toggle toolbar" shortcut now works. Broken features made real (native APIs only, no jugaad): * FindInPageDialog: setFindListener + findNext + clearMatches -> live "n of m" counter, prev/next, highlights cleaned on dismiss. Was: findAllAsync + a toast. * PageTranslate: *.translate.goog page proxy (Chrome's own mechanism) with _x_tr_pto=wapp, remembered target language and "Show original". Was: a URL Google shut down years ago. * Search overlay: '+' icon on the close button fixed, fake Google Lens removed, dark-only hex replaced with theme colours, strings extracted, focus returned to the page, precise list updates. Light/dark consistency: 87 hard-coded text colours in the settings layouts (and Color.WHITE in SettingsRemoteSection) routed to google_text_* - labels were invisible on white cards in light theme. Low-end (1-2GB TV box) & lifecycle: * offscreenPreRaster only for the attached tab (no extra paint buffer per background tab). * Home dashboard stats sampled on Dispatchers.Default (was ActivityManager IPC + StatFs on the UI thread, every second), storage 1-in-5 ticks, no stacking, stops when hidden. * clearCache() no longer leaks a WebView and runs on Main. * onTrimMemory() stopped flushing the favicon cache on every backgrounding. * NetworkCallback replaces the deprecated CONNECTIVITY_CHANGE broadcast. * Incognito activity declares the same configChanges as MainActivity (USB keyboard plug-in no longer destroys private tabs); QUERY_ALL_PACKAGES removed; first-party hosts pinned to TLS; Safe Browsing now on by default with a real Settings switch. Not compiled here (no JDK/Android SDK in the sandbox): build + run the §6 checklist in Android Studio. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- AGENTS.md | 15 +- .../main/java/com/gothwad/browser/Config.kt | 19 ++ app/src/main/AndroidManifest.xml | 19 +- .../browser/activity/main/MainActivity.kt | 76 ++++- .../browser/activity/main/MainActivityDpad.kt | 10 +- .../activity/main/MainActivityNavigation.kt | 12 +- .../browser/activity/main/MainActivityTabs.kt | 4 +- .../browser/activity/main/MainActivityUI.kt | 133 ++++++--- .../activity/main/dialogs/ChromeMenuPopup.kt | 81 +++--- .../activity/main/dialogs/FindInPageDialog.kt | 214 ++++++++++++++ .../activity/main/dialogs/PageTranslate.kt | 126 ++++++++ .../activity/main/view/home/NativeHomeView.kt | 155 +++++++--- .../main/view/search/SearchOverlayView.kt | 26 +- .../browser/settings/MainSettingsView.kt | 1 + .../browser/settings/SettingsEngineSection.kt | 21 ++ .../browser/settings/SettingsRemoteSection.kt | 2 +- .../browser/webengine/webview/WebViewEx.kt | 30 +- .../webengine/webview/WebViewWebEngine.kt | 13 +- app/src/main/res/layout/activity_main.xml | 98 ++++++- .../res/layout/view_chrome_search_screen.xml | 30 +- .../main/res/layout/view_settings_main.xml | 181 +++++++----- .../res/layout/view_settings_shortcuts.xml | 6 +- .../main/res/layout/view_settings_version.xml | 18 +- app/src/main/res/values/strings.xml | 31 ++ .../main/res/xml/network_security_config.xml | 17 ++ docs/AUDIT-2026-10-14.md | 271 ++++++++++++++++++ 26 files changed, 1345 insertions(+), 264 deletions(-) create mode 100644 app/src/main/java/com/gothwad/browser/activity/main/dialogs/FindInPageDialog.kt create mode 100644 app/src/main/java/com/gothwad/browser/activity/main/dialogs/PageTranslate.kt create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 docs/AUDIT-2026-10-14.md diff --git a/AGENTS.md b/AGENTS.md index fc4a7443..d71adf60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,9 @@ These are not style preferences. Each one maps to a real, previously-shipped bug 10. **Background tab memory management must be LRU-based and configurable, never fully disabled and never fully unbounded.** Tabs beyond `Config.maxLiveTabs` should be trimmed (state-saved, WebView released) oldest-first, transparently restored on revisit. The OS-level `onTrimMemory`/`onLowMemory` reactive safety net must always remain in place regardless of any proactive LRU logic. 11. **The system clipboard listener must guard against feedback loops** (the app's own clipboard writes must not create duplicate history entries) **and must respect incognito mode and foreground-only clipboard access** (Android 10+ restricts background clipboard reads — do not attempt them). 12. **Any new UI list, popup, or dialog added to the app (including new Sidebar popups) must follow the same standards as the rest of the app** — precise RecyclerView updates (rule 5), no blocking calls (rule 6), and must be reachable/dismissible correctly via D-pad, keyboard, and mouse alike. -13. **Page zoom logic lives exclusively in `PageZoomController.kt` and implements Chrome-desktop-style density zoom via dynamic `` width override injected at document-start and on zoom changes. Native `WebView.zoomIn()`/`zoomOut()`/`zoomBy()` was replaced because it performed photo-style magnification without responsive layout reflow. Do NOT reintroduce `textZoom`, `setInitialScale()`, custom CSS `zoom` property injection, or switch back to native `zoomIn()`/`zoomOut()` as the primary zoom mechanism.** +13. **Phone vs TV layout is decided in exactly one place: `MainActivityUI.isCompactChromeLayout()`** (`!Utils.isTV() && smallestScreenWidthDp < 600`). The phone layout is *top address pill + bottom Chrome toolbar* (`llBottomPanel`), the TV/tablet layout is the full top header plus the optional top tab bar. Never hide a header control by hand in another file, never give a layout branch its own visibility logic, and never `requestFocus()` on a control that the current layout switched off — use `requestHeaderFocus()`. `getHeaderFocusableViews()` must keep returning only *currently visible* controls so D-pad/keyboard focus can never land on a hidden view. +14. **A menu entry must do the real thing or not exist.** "Find in page" uses `setFindListener`/`findNext`/`clearMatches`; "Translate this page" uses the `*.translate.goog` page proxy (`PageTranslate.kt`) — not a dead `translate.google.com/translate?u=` URL, not a toast that just says "searching". Any future "boost/clean/optimize" UI must call a real API (e.g. `TabsModel.onTrimMemory()`); `System.gc()` is not a feature and must not be presented as one. +15. **Page zoom logic lives exclusively in `PageZoomController.kt` and implements Chrome-desktop-style density zoom via dynamic `` width override injected at document-start and on zoom changes. Native `WebView.zoomIn()`/`zoomOut()`/`zoomBy()` was replaced because it performed photo-style magnification without responsive layout reflow. Do NOT reintroduce `textZoom`, `setInitialScale()`, custom CSS `zoom` property injection, or switch back to native `zoomIn()`/`zoomOut()` as the primary zoom mechanism.** --- @@ -165,7 +167,16 @@ Keep this list updated whenever a significant bug is found and fixed. This is th | 16 | Zoomed pages had page elements (e.g. Google's account/apps dropdown) rendering in the wrong screen position; zoom/scroll felt less smooth than stock Chrome | Custom JS-injected CSS `zoom` property on ``, forcing width/min-width percentage overrides, broke `position: fixed`/`absolute` coordinate math and forced layout reflow instead of using native GPU-scale zoom | Removed the CSS-zoom injection; TV-remote zoom controls now call WebView's native `zoomIn()`/`zoomOut()`/`zoomBy()` (already enabled via `setSupportZoom`/`builtInZoomControls`) | ✅ Fixed | | 17 (open) | `notifyDataSetChanged()` still used in several adapters (History, Downloads, Favorites, and multiple new `*SidebarPopup.kt` files) | Precise-update fix was only applied to the highest-frequency adapters (`TopTabsAdapter`, `TabsRowAdapter`); newer sidebar UI was added afterward using the old pattern | Not yet fixed — apply rule 5/§3 to all remaining adapters, including any new ones | 🟡 Open — low priority, not a functional bug, but should be closed out | | 18 | Page zoom felt like photo magnification instead of desktop-Chrome-style density change; responsive pages never reflowed into desktop layouts | `WebView.zoomIn()`/`zoomOut()`/`zoomBy()` perform photo-style uniform magnification of the rendered bitmap without changing layout viewport width | Rewrote `PageZoomController.kt` to dynamically overwrite `` with target CSS viewport width injected at document-start and on zoom changes, matching desktop Chrome's Ctrl+/Ctrl- density reflow behavior without breaking fixed-position elements | ✅ Fixed | + | 19 | Desktop mode toggle stuck in desktop loop; switching back to mobile mode failed to clear desktop user agent or restore mobile zoom | `WebViewEx.isDesktopModeEnabled()` checked `settings.userAgentString?.contains("Windows")`, which remained true after user untoggled desktop mode; also `applyDesktopMode()` skipped setting userAgentString when null, preventing WebView default UA restoration | Centralized desktop mode detection in `Config.isDesktopMode()`, unconditionally cleared `settings.userAgentString = null` to restore WebView default mobile UA, aligned initialization sequence in `WebViewWebEngine`, and fortified `PageZoomController` runtime script with `MutationObserver` | ✅ Fixed | +| 20 | On a phone the "Chrome" header was 9 widgets in one 52dp row: the omnibox collapsed to a few dp, and the bottom toolbar that the layout already declared (`llBottomPanel`) was a permanently-GONE `0dp` `ConstraintLayout`. `toggleMenu()`/`hideMenuOverlay(hideBottomButtons)` were no-ops, so the "toggle toolbar" shortcut did nothing and focus could be dropped on a GONE button | The mobile layout was grown out of the TV header instead of a real responsive layout; the bottom-bar parameter was never read | `activity_main.xml` now owns a real Chrome bottom toolbar (back · home · new tab · tabs badge · menu) and the page container sits `layout_above` it. `MainActivityUI.isCompactChromeLayout()` (not-TV && `smallestScreenWidthDp < 600`) selects phone-vs-TV layout, `requestHeaderFocus()` only focuses visible controls, `getHeaderFocusableViews()` includes the bottom row so D-pad/keyboard focus still cycles | ✅ Fixed | +| 21 | Settings labels were invisible in a light theme | `view_settings_main.xml` pinned `textColor="#FFFFFF"` (×31) and `#38BDF8` (×25) on cards whose `google_card_bg` is white by day; `SettingsRemoteSection` used `Color.WHITE` for the device list | All settings labels routed to `google_text_primary` / `google_text_secondary` / `google_accent` (which do have night variants). **Remaining:** the sidebar/dialog/activity layouts still carry raw dark hex — tracked as work package W2 in `docs/AUDIT-2026-10-14.md` | 🟡 Partially fixed | +| 22 | "Find in page" only highlighted internally: no match count, no next/previous, highlights left behind | `ChromeMenuPopup.showFindInPageDialog()` called `findAllAsync()` and never used `setFindListener`/`findNext`/`clearMatches` | New `FindInPageDialog.kt` wires the three native WebView APIs (debounced `findAllAsync`, "n of m" counter, prev/next, cleanup on dismiss). Native mechanism, no injected JS | ✅ Fixed | +| 23 | "Translate this page" opened a dead endpoint | `https://translate.google.com/translate?sl=..&tl=..&u=..` has been shut down by Google for years | `PageTranslate.kt` builds the `*.translate.goog` page proxy URL that Chrome itself uses, with `_x_tr_pto=wapp` so no Google toolbar is injected, a remembered target language (`Config.translateTargetLanguage`) and a "Show original" toggle | ✅ Fixed | +| 24 | Safe Browsing was silently disabled app-wide while the README claimed "the securest browser" | `WebViewEx` set `safeBrowsingEnabled = false` and the manifest set `android.webkit.WebView.EnableSafeBrowsing=false` | Both now follow `Config.safeBrowsingEnabled` (default **on**) with a real switch under *Settings → Web Engine & Rendering*. Related follow-ups (mixed content, `allowFileAccess`) = W3 | ✅ Fixed | +| 25 | "Clear cache" leaked a whole WebView renderer; the home-screen dashboard did `ActivityManager` IPC + `StatFs` on the UI thread every second; `onTrimMemory` threw away the favicon cache on every backgrounding; connectivity used the deprecated `CONNECTIVITY_CHANGE` broadcast | throwaway `WebView(ctx)` never destroyed; a 1s main-thread `Handler` ticker; `level >= TRIM_MEMORY_RUNNING_LOW` also matched `TRIM_MEMORY_UI_HIDDEN`; `activeNetworkInfo` deprecated/undelivered | `clearCache` runs on Main and destroys the view; dashboard sampling moved to `Dispatchers.Default` (storage 1-in-5 ticks, no stacking, stops when hidden); trim-level check made explicit; `registerDefaultNetworkCallback` replaces the broadcast; `offscreenPreRaster` is enabled only for the attached tab (background tabs no longer hold an extra paint buffer each — matters on 1–2 GB boxes) | ✅ Fixed | +| 26 | Plugging a USB keyboard into a TV (or toggling system dark mode) destroyed every incognito tab | `.activity.IncognitoModeMainActivity` declared `configChanges="orientation|screenSize|screenLayout"` while the main activity also declares `keyboard|keyboardHidden|navigation|…` | Incognito activity now declares the same configChanges set | ✅ Fixed | +| 27 (open) | `QUERY_ALL_PACKAGES` requested (Play-restricted) though only `VIEW`/speech/install intents are resolved; app's own update/filter-list requests could run over cleartext | permission added by reflex; `usesCleartextTraffic="true"` is needed for browsing `http://` but was also applied to first-party calls | Permission removed, explicit `` added, `network_security_config.xml` pins first-party hosts to TLS. **Also open:** the repo is a derivative of the GPL-3.0-or-later **TV Bro** project (`Config.HOME_PAGE_URL` still points at `tvbrowser.phlox.dev`) while `README.md` declares "all rights reserved / no licence" and the About screen declares "100% open source" — see `docs/AUDIT-2026-10-14.md` §0. This must be resolved before publishing | 🟡 Open — needs the owner's decision | --- @@ -184,6 +195,8 @@ Because this app has a history of one fix quietly breaking another area, treat t - [ ] Load a page in both system light mode and system dark mode — page colors are identical (true to the site) in both. - [ ] Scroll a long page — feels smooth, no stutter. - [ ] No new `runBlocking`, synchronous I/O on main/WebView threads, or `notifyDataSetChanged()` introduced by the change (grep for these before finishing). +- [ ] Phone (portrait, 360dp) and TV layouts both checked: bottom toolbar visible only on phones, no white-on-white text in either theme, search overlay + find-in-page + translate reachable by touch, D-pad and keyboard. +- [ ] Settings screen opened in BOTH light and dark theme — every label readable. - [ ] No device/chipset/OEM-specific code introduced. --- diff --git a/app/common/src/main/java/com/gothwad/browser/Config.kt b/app/common/src/main/java/com/gothwad/browser/Config.kt index 7b8338cb..0f896b87 100644 --- a/app/common/src/main/java/com/gothwad/browser/Config.kt +++ b/app/common/src/main/java/com/gothwad/browser/Config.kt @@ -76,6 +76,8 @@ class Config(val prefs: SharedPreferences) { const val KEEP_ALIVE_IN_BACKGROUND_KEY = "keep_alive_in_background" const val MAX_LIVE_TABS_KEY = "max_live_tabs" const val SAVE_HISTORY_KEY = "save_history" + const val TRANSLATE_TARGET_LANGUAGE_KEY = "translate_target_language" + const val SAFE_BROWSING_KEY = "safe_browsing_enabled" /** * Maximum number of tab WebViews kept fully live in memory simultaneously. * Default is 6: balanced for typical Android TV boxes (1.5GB - 3GB RAM) to allow @@ -332,6 +334,23 @@ class Config(val prefs: SharedPreferences) { setEffectiveZoom(isDesktopMode(), value) } + /** Target language code for "Translate this page" ("" = device language). */ + var translateTargetLanguage: String + get() = prefs.getString(TRANSLATE_TARGET_LANGUAGE_KEY, "") ?: "" + set(value) { + prefs.edit().putString(TRANSLATE_TARGET_LANGUAGE_KEY, value).apply() + } + + /** + * Chrome-like Safe Browsing checks for navigations and downloads. Enabled by default; it can + * be turned off for debugging or on very constrained devices where the extra lookups hurt. + */ + var safeBrowsingEnabled: Boolean + get() = prefs.getBoolean(SAFE_BROWSING_KEY, true) + set(value) { + prefs.edit().putBoolean(SAFE_BROWSING_KEY, value).apply() + } + /** Whether to record visited URLs and searches into local history. */ var saveHistory: Boolean get() = prefs.getBoolean(SAVE_HISTORY_KEY, true) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3343d1de..759cb3bc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,8 +17,6 @@ - + + + + + + + + + + + @@ -78,6 +88,7 @@ android:largeHeap="true" android:supportsRtl="true" android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/AppTheme" android:requestLegacyExternalStorage="true" tools:ignore="DiscouragedApi" > @@ -139,7 +150,7 @@ android:label="@string/app_name_short" android:screenOrientation="unspecified" android:launchMode="singleTask" - android:configChanges="orientation|screenSize|screenLayout" + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density|uiMode|layoutDirection|locale|touchscreen" tools:ignore="DiscouragedApi" /> + android:value="true" /> = TRIM_MEMORY_RUNNING_LOW || level >= TRIM_MEMORY_MODERATE) { + // Only drop the icon cache when memory is genuinely tight. `level >= TRIM_MEMORY_RUNNING_LOW` + // also matched TRIM_MEMORY_UI_HIDDEN, so the whole pool was cleared on every backgrounding, + // making every tab re-fetch/decode its favicons on return (visible flicker + wasted CPU). + if (level == TRIM_MEMORY_RUNNING_CRITICAL || + level == TRIM_MEMORY_COMPLETE || + level == TRIM_MEMORY_MODERATE || + level == TRIM_MEMORY_BACKGROUND + ) { FaviconsPool.clear() } } @@ -354,13 +368,41 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback { super.onResume() applySoftInputMode() HardwareMousePointerManager.applyToWindow(window) - val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE") - registerReceiver(mConnectivityChangeReceiver, intentFilter) + registerNetworkCallback() + onNetworkStateChanged() tabsModel.currentTab.value?.webEngine?.onResume() } + private fun registerNetworkCallback() { + if (networkCallback != null) return + val cm = connectivityManager ?: return + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: android.net.Network) = onNetworkStateChanged() + override fun onLost(network: android.net.Network) = onNetworkStateChanged() + override fun onCapabilitiesChanged( + network: android.net.Network, + capabilities: android.net.NetworkCapabilities + ) = onNetworkStateChanged() + } + try { + cm.registerDefaultNetworkCallback(callback) + networkCallback = callback + } catch (e: Exception) { + Log.w(TAG, "Could not register default network callback", e) + } + } + + private fun unregisterNetworkCallback() { + val callback = networkCallback ?: return + networkCallback = null + try { + connectivityManager?.unregisterNetworkCallback(callback) + } catch (e: Exception) { + Log.w(TAG, "Could not unregister default network callback", e) + } + } + override fun onPause() { - unregisterReceiver(mConnectivityChangeReceiver) tabsModel.currentTab.value?.let { tab -> tab.webEngine.onPause() tab.onPause() @@ -393,7 +435,13 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback { super.attachBaseContext(newBase) } - fun focusDefaultNavigationElement() { vb.ibMenu.requestFocus() } + fun focusDefaultNavigationElement() { + if (isBottomToolbarVisible()) { + vb.ibBottomMenu.requestFocus() + } else { + vb.ibMenu.requestFocus() + } + } fun showHome() { showHomeScreen() } fun handleBack() { handleBackNavigation() } fun handleMenu() { showBrowserSidebar() } diff --git a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityDpad.kt b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityDpad.kt index eb6db363..c0941958 100644 --- a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityDpad.kt +++ b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityDpad.kt @@ -30,7 +30,15 @@ fun MainActivity.getHeaderFocusableViews(): List { vb.ibDownloads, vb.ibMenu ) - for (v in candidateViews) { + val bottomBarViews = listOf( + vb.ibBottomBack, + vb.ibBottomHome, + vb.ibBottomNewTab, + vb.flBottomTabsSwitcher, + vb.ibBottomMenu + ) + val all = candidateViews + bottomBarViews + for (v in all) { if (v.isShown && v.visibility == View.VISIBLE) { list.add(v) } diff --git a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityNavigation.kt b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityNavigation.kt index f380d6aa..a4a5241e 100644 --- a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityNavigation.kt +++ b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityNavigation.kt @@ -108,6 +108,12 @@ internal fun MainActivity.updateBackForwardButtons(canGoBack: Boolean, canGoForw vb.ibBack.isFocusable = true vb.ibBack.alpha = if (canGoBack) 1.0f else 0.4f + // The phone layout owns its own Back button in the bottom toolbar - keep it in sync, + // otherwise it stays visually enabled on the home screen where there is nothing to go back to. + vb.ibBottomBack.isEnabled = true + vb.ibBottomBack.isFocusable = true + vb.ibBottomBack.alpha = if (canGoBack) 1.0f else 0.4f + vb.ibForward.isEnabled = true vb.ibForward.isFocusable = true vb.ibForward.alpha = if (canGoForward) 1.0f else 0.4f @@ -143,7 +149,7 @@ internal fun MainActivity.showHomeScreen() { canGoForward = !currentTab?.lastUrlBeforeHome.isNullOrEmpty() ) showMenuOverlay() - vb.ibHome.requestFocus() + requestHeaderFocus() } internal fun MainActivity.switchToTab(newTab: WebTabState) { @@ -159,7 +165,7 @@ internal fun MainActivity.switchToTab(newTab: WebTabState) { canGoForward = !newTab.lastUrlBeforeHome.isNullOrEmpty() ) showMenuOverlay() - vb.ibHome.requestFocus() + requestHeaderFocus() } else { vb.vNativeHome.visibility = View.GONE vb.flWebViewContainer.visibility = View.VISIBLE @@ -278,7 +284,7 @@ internal fun MainActivity.onWebViewUpdated(tab: WebTabState) { showMenuOverlay() val current = currentFocus if (current == null || (!isToolbarView(current) && !vb.vNativeHome.hasFocus())) { - vb.ibHome.post { vb.ibHome.requestFocus() } + vb.ibHome.post { requestHeaderFocus() } } } else { updateBackForwardButtons( diff --git a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityTabs.kt b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityTabs.kt index 89ddfe8a..4d317f3c 100644 --- a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityTabs.kt +++ b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityTabs.kt @@ -69,7 +69,9 @@ fun MainActivity.showTabsRowDialog() { fun MainActivity.updateTabCountBadge() { val count = tabsModel.tabsStates.size - vb.tvTabCountBadge.text = if (count > 0) count.toString() else "1" + val label = if (count > 0) count.toString() else "1" + vb.tvTabCountBadge.text = label + vb.tvBottomTabCountBadge.text = label refreshTopTabs() if (vb.vChromeTabSwitcher.isVisible) { vb.vChromeTabSwitcher.updateTabs(tabsModel.tabsStates, tabsModel.currentTab.value) diff --git a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityUI.kt b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityUI.kt index 18a99681..e7ccc02d 100644 --- a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityUI.kt +++ b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityUI.kt @@ -29,26 +29,79 @@ import com.gothwad.browser.utils.Utils import com.gothwad.browser.utils.VoiceSearchHelper import com.gothwad.browser.webengine.WebEngineFactory +/** + * True on hand-sized (phone) layouts, where the browser must look like stock Chrome for + * Android: an address pill on top and Back / Home / New tab / Tabs / Menu on the bottom bar. + * TV, and any layout that is not a real touchscreen phone, keeps the full top header because + * D-pad focus navigation needs those buttons visible at the top. + */ +fun MainActivity.isCompactChromeLayout(): Boolean { + if (!isVbInitialized) return false + if (Utils.isTV(this)) return false + val smallestWidth = resources.configuration.smallestScreenWidthDp + return smallestWidth in 1..599 +} + +/** Whether the Chrome-style bottom toolbar is currently the active navigation row. */ +fun MainActivity.isBottomToolbarVisible(): Boolean = + isCompactChromeLayout() && vb.llBottomPanel.visibility == View.VISIBLE + internal fun MainActivity.showMenuOverlay() { if (isFullscreen) return vb.vActionBar.dismissExtendedAddressBarMode() vb.rlActionBar.visibility = View.VISIBLE vb.rlActionBar.translationY = 0f + if (isCompactChromeLayout()) { + vb.llBottomPanel.visibility = View.VISIBLE + } } +@Suppress("UNUSED_PARAMETER") internal fun MainActivity.hideMenuOverlay(hideBottomButtons: Boolean = true) { if (isFullscreen) { vb.rlActionBar.visibility = View.GONE + vb.llBottomPanel.visibility = View.GONE } else { vb.rlActionBar.visibility = View.VISIBLE vb.rlActionBar.translationY = 0f + // Phone layout keeps its bottom toolbar persistent, exactly like stock Chrome does while + // the address bar is not being edited. Hiding it would strand a touch-only user with no + // way to get the header back (the header-recovery path is D-pad driven). + if (isCompactChromeLayout()) { + vb.llBottomPanel.visibility = View.VISIBLE + } } } internal fun MainActivity.toggleMenu() { if (isFullscreen) return - vb.rlActionBar.visibility = View.VISIBLE - vb.rlActionBar.translationY = 0f + if (isCompactChromeLayout()) { + // Nothing to collapse on a phone: the header holds the only address bar. + showMenuOverlay() + return + } + if (vb.rlActionBar.visibility == View.VISIBLE) { + // TV/tablet: collapse the header for a full-bleed page. UP from the page, or BACK, + // brings it back (see focusHeaderViewNearX() / handleBackNavigation()). + vb.rlActionBar.visibility = View.GONE + vb.llBottomPanel.visibility = View.GONE + } else { + showMenuOverlay() + } +} + +/** + * Focus the first control that is actually visible in the current layout. Never focus a button + * that the compact/expanded layout switched off — a `requestFocus()` on a GONE view silently + * drops focus on the floor and the D-pad stops responding. + */ +fun MainActivity.requestHeaderFocus() { + val candidates = if (isBottomToolbarVisible()) { + listOf(vb.ibBottomHome, vb.ibBottomBack, vb.flBottomTabsSwitcher, vb.ibBottomMenu) + } else { + listOf(vb.ibHome, vb.ibHistory, vb.ibBack, vb.flTabsSwitcher, vb.ibMenu) + } + candidates.firstOrNull { it.isShown }?.requestFocus() } internal fun MainActivity.setupHeaderClickListeners(incognitoMode: Boolean) { @@ -82,6 +135,22 @@ internal fun MainActivity.setupHeaderClickListeners(incognitoMode: Boolean) { vb.ibRefresh.setOnClickListener { refresh() } vb.ibDownloads.setOnClickListener { showDownloads(vb.ibDownloads) } + // Chrome-style bottom toolbar (phone / compact-width layouts only) + vb.ibBottomBack.setOnClickListener { navigateBack() } + vb.ibBottomHome.setOnClickListener { + if (vb.vNativeHome.visibility == View.VISIBLE) { + vb.vNativeHome.scrollToTop() + } else { + showHomeScreen() + } + } + vb.ibBottomNewTab.setOnClickListener { + openInNewTab(settingsModel.homePage, tabsModel.tabsStates.size, needToHideMenuOverlay = false, navigateImmediately = true) + } + vb.flBottomTabsSwitcher.setOnClickListener { showTabsRowDialog() } + vb.tvBottomTabCountBadge.setOnClickListener { showTabsRowDialog() } + vb.ibBottomMenu.setOnClickListener { showChromeMenu(vb.ibBottomMenu) } + vb.vSearchOverlay.onPerformSearch = { query -> vb.vActionBar.dismissExtendedAddressBarMode() search(query) @@ -141,7 +210,8 @@ internal fun MainActivity.setupHeaderClickListeners(incognitoMode: Boolean) { vb.ibTopTabSearch, vb.ibTopHideBars, vb.ibTopCloseApp, vb.ibTopNewTab, vb.ibHistory, vb.ibHome, vb.ibBack, vb.ibForward, vb.ibRefresh, - vb.ibNewTab, vb.flTabsSwitcher, vb.ibDownloads, vb.ibMenu + vb.ibNewTab, vb.flTabsSwitcher, vb.ibDownloads, vb.ibMenu, + vb.ibBottomBack, vb.ibBottomHome, vb.ibBottomNewTab, vb.flBottomTabsSwitcher, vb.ibBottomMenu ).forEach { it.isFocusable = true it.isFocusableInTouchMode = false @@ -383,42 +453,35 @@ internal fun MainActivity.setupDragAndDropListener() { fun MainActivity.updateToolbarLayoutForOrientation() { if (!isVbInitialized) return - val isPortrait = config.screenOrientation == Config.ORIENTATION_PORTRAIT || - (config.screenOrientation != Config.ORIENTATION_LANDSCAPE && - (resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT || - resources.configuration.screenWidthDp < 600)) - - if (isPortrait) { - // Mobile Portrait Chrome Layout: - // Hide individual navigation/action buttons to give search/address bar space + if (isCompactChromeLayout()) { + // ---- Stock Chrome phone layout ---- + // Top row: the address pill only (site-security / search-engine icon, text, mic). + // Bottom row: Back, Home, New tab, Tabs (count badge), Menu. + vb.llTopTabBar.visibility = View.GONE vb.ibHistory.visibility = View.GONE + vb.ibHome.visibility = View.GONE vb.ibBack.visibility = View.GONE vb.ibForward.visibility = View.GONE vb.ibRefresh.visibility = View.GONE + vb.ibNewTab.visibility = View.GONE + vb.flTabsSwitcher.visibility = View.GONE vb.ibDownloads.visibility = View.GONE - - // Hide desktop-style top tabs bar in portrait (tabs managed via flTabsSwitcher badge & 3-dot menu) - vb.llTopTabBar.visibility = View.GONE - - // Pure Chrome Mobile Header: Home, Search Bar, Plus (+ Add Tab), Tab count badge, 3-dot Menu - vb.ibHome.visibility = View.VISIBLE - vb.ibNewTab.visibility = View.VISIBLE - vb.flTabsSwitcher.visibility = View.VISIBLE - vb.ibMenu.visibility = View.VISIBLE - } else { - // Landscape / TV Mode: - vb.ibHistory.visibility = View.VISIBLE - vb.ibHome.visibility = View.VISIBLE - vb.ibBack.visibility = View.VISIBLE - vb.ibForward.visibility = View.VISIBLE - vb.ibRefresh.visibility = View.VISIBLE - vb.ibNewTab.visibility = View.VISIBLE - vb.flTabsSwitcher.visibility = View.VISIBLE - vb.ibDownloads.visibility = View.VISIBLE - vb.ibMenu.visibility = View.VISIBLE - - vb.llTopTabBar.visibility = if (config.showTopTabBar.value) View.VISIBLE else View.GONE + vb.ibMenu.visibility = View.GONE + vb.llBottomPanel.visibility = View.VISIBLE + vb.rlActionBar.visibility = if (isFullscreen) View.GONE else View.VISIBLE + return } -} - + // ---- TV / tablet layout: everything stays in the top header for D-pad focus ---- + vb.llBottomPanel.visibility = View.GONE + vb.ibHistory.visibility = View.VISIBLE + vb.ibHome.visibility = View.VISIBLE + vb.ibBack.visibility = View.VISIBLE + vb.ibForward.visibility = View.VISIBLE + vb.ibRefresh.visibility = View.VISIBLE + vb.ibNewTab.visibility = View.VISIBLE + vb.flTabsSwitcher.visibility = View.VISIBLE + vb.ibDownloads.visibility = View.VISIBLE + vb.ibMenu.visibility = View.VISIBLE + vb.llTopTabBar.visibility = if (config.showTopTabBar.value) View.VISIBLE else View.GONE +} diff --git a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/ChromeMenuPopup.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/ChromeMenuPopup.kt index bacac785..19d4c021 100644 --- a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/ChromeMenuPopup.kt +++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/ChromeMenuPopup.kt @@ -145,16 +145,20 @@ class ChromeMenuPopup(private val activity: MainActivity) { } favDao.insert(newItem) withContext(Dispatchers.Main) { - Toast.makeText(activity, "Bookmark added: $title", Toast.LENGTH_SHORT).show() + Toast.makeText( + activity, + activity.getString(R.string.bookmark_added, title), + Toast.LENGTH_SHORT + ).show() } } else { withContext(Dispatchers.Main) { - Toast.makeText(activity, "Already in bookmarks", Toast.LENGTH_SHORT).show() + Toast.makeText(activity, R.string.bookmark_already_exists, Toast.LENGTH_SHORT).show() } } } } else { - Toast.makeText(activity, "No active webpage to bookmark", Toast.LENGTH_SHORT).show() + Toast.makeText(activity, R.string.bookmark_no_page, Toast.LENGTH_SHORT).show() } } @@ -256,18 +260,26 @@ class ChromeMenuPopup(private val activity: MainActivity) { showFindInPageDialog() } - // 10. Translate... + // 10. Translate this page (Chrome-style, via the page proxy - see PageTranslate helper) bindMenuItem(contentView.findViewById(R.id.btnMenuTranslate)) { - if (isWebPage) { - try { - val encoded = URLEncoder.encode(currentUrl, "UTF-8") - val translateUrl = "https://translate.google.com/translate?sl=auto&tl=en&u=$encoded" - activity.navigate(translateUrl) - } catch (e: Exception) { - Toast.makeText(activity, "Unable to translate page", Toast.LENGTH_SHORT).show() - } + if (!isWebPage || !PageTranslate.isTranslatable(currentUrl)) { + Toast.makeText(activity, R.string.translate_unsupported, Toast.LENGTH_SHORT).show() + return@bindMenuItem + } + val original = PageTranslate.originalUrlOf(currentUrl) + if (original != null) { + // Already on a translated copy -> go back to the source page, like Chrome's + // "Show original" toggle. + activity.navigate(original) } else { - Toast.makeText(activity, "No webpage to translate", Toast.LENGTH_SHORT).show() + PageTranslate.pickTargetLanguage(activity) { lang -> + val proxyUrl = PageTranslate.buildProxyUrl(currentUrl, lang) + if (proxyUrl == null) { + Toast.makeText(activity, R.string.translate_failed, Toast.LENGTH_SHORT).show() + } else { + activity.navigate(proxyUrl) + } + } } } @@ -286,16 +298,23 @@ class ChromeMenuPopup(private val activity: MainActivity) { document.body.classList.add('tv-reading-mode'); var s = document.createElement('style'); s.id = 'tv-reading-style'; - s.innerHTML = 'body.tv-reading-mode { max-width: 800px !important; margin: 0 auto !important; padding: 24px !important; font-size: 20px !important; line-height: 1.6 !important; background: #1a1a1a !important; color: #e0e0e0 !important; }'; + s.innerHTML = 'body.tv-reading-mode { max-width: 800px !important; margin: 0 auto !important; padding: 24px !important; font-size: 20px !important; line-height: 1.6 !important; }'; document.head.appendChild(s); } + return document.body.classList.contains('tv-reading-mode') ? 1 : 0; })(); """.trimIndent() - webView.evaluateJavascript(js, null) - Toast.makeText(activity, "Reading mode toggled", Toast.LENGTH_SHORT).show() + webView.evaluateJavascript(js) { result -> + val enabled = result?.trim()?.trim('"') == "1" + Toast.makeText( + activity, + if (enabled) R.string.reading_mode_toggled_on else R.string.reading_mode_toggled_off, + Toast.LENGTH_SHORT + ).show() + } } } else { - Toast.makeText(activity, "Reading mode not available on this page", Toast.LENGTH_SHORT).show() + Toast.makeText(activity, R.string.reading_mode_unavailable, Toast.LENGTH_SHORT).show() } } @@ -337,7 +356,11 @@ class ChromeMenuPopup(private val activity: MainActivity) { tab.webEngine.setPageZoom(newZoom) } currentTab?.webEngine?.reload() - Toast.makeText(activity, if (willBeDesktop) "Desktop site enabled" else "Mobile site enabled", Toast.LENGTH_SHORT).show() + Toast.makeText( + activity, + activity.getString(if (willBeDesktop) R.string.desktop_site_on else R.string.desktop_site_off), + Toast.LENGTH_SHORT + ).show() } // 14. Settings @@ -415,27 +438,7 @@ class ChromeMenuPopup(private val activity: MainActivity) { } private fun showFindInPageDialog() { - val currentTab = activity.tabsModel.currentTab.value ?: return - val webView = currentTab.webEngine.getView() as? WebView ?: return - - val input = EditText(activity).apply { - hint = "Search on webpage..." - setSingleLine(true) - setPadding(32, 24, 32, 24) - } - - AlertDialog.Builder(activity) - .setTitle("Find in page") - .setView(input) - .setPositiveButton("Find") { _, _ -> - val query = input.text.toString().trim() - if (query.isNotEmpty()) { - webView.findAllAsync(query) - Toast.makeText(activity, "Searching for: $query", Toast.LENGTH_SHORT).show() - } - } - .setNegativeButton("Cancel", null) - .show() + FindInPageDialog.show(activity) } fun show(anchorView: View) { diff --git a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/FindInPageDialog.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/FindInPageDialog.kt new file mode 100644 index 00000000..a30987a7 --- /dev/null +++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/FindInPageDialog.kt @@ -0,0 +1,214 @@ +package com.gothwad.browser.activity.main.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.view.Gravity +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.webkit.WebView +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.TextView +import com.gothwad.browser.R +import androidx.core.content.ContextCompat +import com.gothwad.browser.activity.main.MainActivity + +/** + * Chrome-style "Find in page". + * + * The previous implementation was a plain prompt dialog that called `findAllAsync()` and then + * forgot about the result: no match counter, no next/previous, no way to leave the highlights + * behind. WebView already exposes all of that natively through [WebView.setFindListener], + * `findNext()` and `clearMatches()` - this only wires those three up (AGENTS.md §0: use the + * native mechanism instead of faking a browser feature). + */ +internal object FindInPageDialog { + + /** Remembered between openings so re-opening the bar keeps the last term, like Chrome. */ + private var lastQuery: String = "" + + fun show(activity: MainActivity) { + val tab = activity.tabsModel.currentTab.value ?: return + val webView = tab.webEngine.getView() as? WebView ?: return + val ctx: Context = activity + + val density = ctx.resources.displayMetrics.density + fun dp(value: Int): Int = (value * density).toInt() + + val input = EditText(ctx).apply { + hint = ctx.getString(R.string.find_in_page_hint) + setText(lastQuery) + setSingleLine(true) + imeOptions = EditorInfo.IME_ACTION_SEARCH + inputType = android.text.InputType.TYPE_CLASS_TEXT or + android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + isFocusableInTouchMode = true + } + + val counter = TextView(ctx).apply { + setTextColor(ContextCompat.getColor(ctx, R.color.day_night_text_secondary)) + textSize = 13f + gravity = Gravity.CENTER + minWidth = dp(72) + text = "" + } + + val btnPrevious = imageButton(ctx, R.drawable.ic_keyboard_arrow_up, R.string.find_previous) + val btnNext = imageButton(ctx, R.drawable.ic_keyboard_arrow_down, R.string.find_next) + val btnClose = imageButton(ctx, R.drawable.ic_close_grey_900_24dp, R.string.find_done) + + val row = LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(6), dp(6), dp(6)) + addView( + input, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + ) + addView(counter, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(40))) + addView(btnPrevious, LinearLayout.LayoutParams(dp(40), dp(40))) + addView(btnNext, LinearLayout.LayoutParams(dp(40), dp(40))) + addView(btnClose, LinearLayout.LayoutParams(dp(40), dp(40))) + } + + var totalMatches = 0 + var activeMatch = 0 + var isDoneCounting = false + + fun refreshCounter() { + counter.text = when { + input.text.isNullOrEmpty() -> "" + !isDoneCounting -> "…" + totalMatches == 0 -> ctx.getString(R.string.find_no_matches) + else -> "$activeMatch of $totalMatches" + } + val found = isDoneCounting && totalMatches > 0 + btnPrevious.isEnabled = found + btnNext.isEnabled = found + btnPrevious.alpha = if (found) 1f else 0.35f + btnNext.alpha = if (found) 1f else 0.35f + } + + val findListener = WebView.FindListener { activeMatchOrdinal, numberOfMatches, doneCounting -> + activeMatch = activeMatchOrdinal + totalMatches = numberOfMatches + isDoneCounting = doneCounting + refreshCounter() + } + webView.setFindListener(findListener) + + fun runFind() { + val query = input.text?.toString().orEmpty() + lastQuery = query + isDoneCounting = false + if (query.isEmpty()) { + totalMatches = 0 + activeMatch = 0 + webView.clearMatches() + isDoneCounting = true + refreshCounter() + } else { + // findAllAsync re-runs the whole search; Chromium keeps the active match index + // when the query is unchanged, which is exactly what Chrome does while typing. + webView.findAllAsync(query) + } + refreshCounter() + } + + var pendingSearch: Runnable? = null + input.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + pendingSearch?.let { input.removeCallbacks(it) } + val runnable = Runnable { runFind() } + pendingSearch = runnable + input.postDelayed(runnable, 220L) // debounce, like Chrome's incremental search + refreshCounter() + } + + override fun afterTextChanged(s: Editable?) {} + }) + + input.setOnKeyListener { _, keyCode, event -> + if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_SEARCH) { + runFind() + true + } else { + false + } + } + + btnNext.setOnClickListener { + if (totalMatches > 0) webView.findNext(true) else runFind() + } + btnPrevious.setOnClickListener { + if (totalMatches > 0) webView.findNext(false) else runFind() + } + + input.setOnEditorActionListener { _, actionId, event -> + val searchAction = actionId == EditorInfo.IME_ACTION_SEARCH || + actionId == EditorInfo.IME_ACTION_DONE || + (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) + if (searchAction) { + pendingSearch?.let { input.removeCallbacks(it) } + if (totalMatches > 0 && !input.text.isNullOrEmpty()) { + webView.findNext(true) + } else { + runFind() + } + true + } else { + false + } + } + + val dialog: AlertDialog = AlertDialog.Builder(ctx) + .setTitle(R.string.find_in_page) + .setView(row) + .setOnDismissListener { + pendingSearch?.let { input.removeCallbacks(it) } + try { + webView.setFindListener(null) + webView.clearMatches() + } catch (e: Throwable) { + // WebView may already be detached/destroyed (tab closed while searching) + } + val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(input.windowToken, 0) + } + .create() + + btnClose.setOnClickListener { dialog.dismiss() } + + dialog.setOnShowListener { + input.requestFocus() + val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT) + if (!input.text.isNullOrEmpty()) { + runFind() + } else { + refreshCounter() + } + } + dialog.show() + } + + private fun imageButton( + ctx: Context, + iconRes: Int, + descriptionRes: Int + ): ImageButton = ImageButton(ctx).apply { + setImageResource(iconRes) + contentDescription = ctx.getString(descriptionRes) + setBackgroundResource(R.drawable.menu_item_selector) + isFocusable = true + isFocusableInTouchMode = true + scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + val pad = (6 * ctx.resources.displayMetrics.density).toInt() + setPadding(pad, pad, pad, pad) + } +} diff --git a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/PageTranslate.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/PageTranslate.kt new file mode 100644 index 00000000..980cb596 --- /dev/null +++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/PageTranslate.kt @@ -0,0 +1,126 @@ +package com.gothwad.browser.activity.main.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.net.Uri +import com.gothwad.browser.AppContext +import java.util.Locale + +/** + * Page translation helper. + * + * The old menu entry pointed at `https://translate.google.com/translate?sl=..&tl=..&u=..`, an + * endpoint Google shut down - it silently landed on a "feature moved" page, so the menu item was + * broken for every user. Chrome itself translates pages through the `*.translate.goog` reverse + * proxy; that is what this builds, so the whole site (markup, CSS, images, relative links) is + * served translated and stays browsable, instead of only translating the URL string. + * + * `_x_tr_pto=wapp` suppresses Google's own toolbar banner, so no Google account/UI chrome leaks + * into the browsing experience. + */ +internal object PageTranslate { + + /** Remembered so "Show original" keeps working while the app process lives. */ + private const val MAX_TRACKED_PAGES = 50 + private val translatedFrom = LinkedHashMap() + + private val languages = linkedMapOf( + "Device language" to "", + "English" to "en", + "हिन्दी (Hindi)" to "hi", + "Español (Spanish)" to "es", + "Français (French)" to "fr", + "Deutsch (German)" to "de", + "Italiano (Italian)" to "it", + "Português (Portuguese)" to "pt", + "Русский (Russian)" to "ru", + "Українська (Ukrainian)" to "uk", + "Polski (Polish)" to "pl", + "Türkçe (Turkish)" to "tr", + "العربية (Arabic)" to "ar", + "فارسی (Persian)" to "fa", + "中文 (Chinese)" to "zh-CN", + "日本語 (Japanese)" to "ja", + "한국어 (Korean)" to "ko", + "తెలుగు (Telugu)" to "te", + "தமிழ் (Tamil)" to "ta", + "বাংলা (Bengali)" to "bn", + "मराठी (Marathi)" to "mr", + "ગુજરાતી (Gujarati)" to "gu", + "ಕನ್ನಡ (Kannada)" to "kn", + "മലയാളം (Malayalam)" to "ml", + "Tiếng Việt (Vietnamese)" to "vi", + "ไทย (Thai)" to "th" + ) + + fun isTranslatable(url: String?): Boolean { + if (url.isNullOrEmpty()) return false + return url.startsWith("http://", true) || url.startsWith("https://", true) + } + + fun defaultTargetLang(): String = Locale.getDefault().language + + /** + * Builds the page-proxy URL for [url]. Returns null when the host cannot be proxied + * (IP literal hostnames have no dot to encode, so they are not covered by the proxy). + */ + fun buildProxyUrl(url: String, targetLang: String): String? { + if (!isTranslatable(url)) return null + val uri = try { + Uri.parse(url) + } catch (e: Exception) { + return null + } + val host = uri.host ?: return null + if (host.isEmpty() || !host.contains('.')) return null + val proxyHost = host.replace('.', '-') + ".translate.goog" + val path = uri.encodedPath.orEmpty() + val query = uri.encodedQuery + val source = StringBuilder(uri.scheme ?: "https").append("://").append(proxyHost).append(path) + if (!query.isNullOrEmpty()) { + source.append('?').append(query) + } + val separator = if (query.isNullOrEmpty()) "?" else "&" + val tl = targetLang.takeIf { it.isNotBlank() } ?: defaultTargetLang() + source.append(separator) + .append("_x_tr_sl=auto") + .append("&_x_tr_tl=").append(tl) + .append("&_x_tr_hl=").append(tl) + .append("&_x_tr_pto=wapp") + val result = source.toString() + remember(result, url) + return result + } + + private fun remember(proxyUrl: String, originalUrl: String) { + synchronized(translatedFrom) { + translatedFrom[proxyUrl] = originalUrl + if (translatedFrom.size > MAX_TRACKED_PAGES) { + val firstKey = translatedFrom.keys.firstOrNull() + if (firstKey != null) translatedFrom.remove(firstKey) + } + } + } + + /** If [url] is one we produced earlier, return the page it was translated from. */ + fun originalUrlOf(url: String): String? = + synchronized(translatedFrom) { translatedFrom[url] } + + fun pickTargetLanguage(context: Context, onPicked: (String) -> Unit) { + val titles = languages.keys.toTypedArray() + val config = AppContext.provideConfig() + val saved = config.translateTargetLanguage + val preselected = languages.values.indexOf(saved).let { if (it >= 0) it else 0 } + AlertDialog.Builder(context) + .setTitle(com.gothwad.browser.R.string.translate_this_page) + .setSingleChoiceItems(titles, preselected) { dialog, which -> + val lang = languages.values.toList()[which] + val effective = lang.ifBlank { defaultTargetLang() } + config.translateTargetLanguage = effective + dialog.dismiss() + onPicked(effective) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} diff --git a/app/src/main/java/com/gothwad/browser/activity/main/view/home/NativeHomeView.kt b/app/src/main/java/com/gothwad/browser/activity/main/view/home/NativeHomeView.kt index 63321682..e089df55 100644 --- a/app/src/main/java/com/gothwad/browser/activity/main/view/home/NativeHomeView.kt +++ b/app/src/main/java/com/gothwad/browser/activity/main/view/home/NativeHomeView.kt @@ -18,6 +18,12 @@ import android.widget.LinearLayout import android.widget.ScrollView import android.widget.Toast import androidx.recyclerview.widget.GridLayoutManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import androidx.recyclerview.widget.RecyclerView import com.gothwad.browser.R import com.gothwad.browser.activity.main.MainActivity @@ -47,10 +53,19 @@ class NativeHomeView @JvmOverloads constructor( private val tickerRunnable = object : Runnable { override fun run() { updateDashboardCardsLive() - tickerHandler.postDelayed(this, 1000L) + tickerHandler.postDelayed(this, DASHBOARD_TICK_MS) } } + private var statsScope: CoroutineScope? = null + private var statsJob: Job? = null + private var tickCounter = 0L + + companion object { + private const val DASHBOARD_TICK_MS = 1000L + private const val STORAGE_TICK_EVERY = 5L + } + init { orientation = VERTICAL setBackgroundResource(R.color.top_bar_background) @@ -231,74 +246,128 @@ class NativeHomeView @JvmOverloads constructor( private fun startDashboardTicker() { tickerHandler.removeCallbacks(tickerRunnable) - tickerHandler.postDelayed(tickerRunnable, 1000L) + tickerHandler.postDelayed(tickerRunnable, DASHBOARD_TICK_MS) } private fun stopDashboardTicker() { tickerHandler.removeCallbacks(tickerRunnable) + statsJob?.cancel() + statsJob = null } + /** + * The stats cards used to read ActivityManager (an IPC), StatFs (a syscall) and TrafficStats + * once per second *on the UI thread*, which is precisely what makes a 1 GB TV box stutter + * while the home screen is up. Sampling now happens on a worker thread, storage only every + * 5th tick (it does not change per second), and a slow sample never stacks up behind the + * next tick. + */ private fun updateDashboardCardsLive() { try { if (!::rvBookmarks.isInitialized || rvBookmarks.isComputingLayout) return if (bookmarksAdapter == null) return if (bookmarkItems.size < 6) return if (!bookmarkItems[0].isHeader || bookmarkItems[0].title != "Gothwad Browser") return - - SystemMonitorHelper.updateNetworkSpeed() - val netInfo = SystemMonitorHelper.getNetworkInfo(context) - val ramStats = SystemMonitorHelper.getRamStats(context) - val storageStats = SystemMonitorHelper.getStorageStats() - - val ramUsedStr = SystemMonitorHelper.formatBytes(ramStats.usedBytes) - val ramTotalStr = SystemMonitorHelper.formatBytes(ramStats.totalBytes) - - val storageFreeStr = SystemMonitorHelper.formatBytes(storageStats.freeBytes) - val storageUsedStr = SystemMonitorHelper.formatBytes(storageStats.usedBytes) - val storageTotalStr = SystemMonitorHelper.formatBytes(storageStats.totalBytes) - - // Pos 1: TIME - bookmarkItems[1] = bookmarkItems[1].copy( - title = SystemMonitorHelper.getFormattedTime(), - subtitleText = SystemMonitorHelper.getTimeSubtitle() - ) - // Pos 2: CALENDAR - bookmarkItems[2] = bookmarkItems[2].copy( - title = SystemMonitorHelper.getFormattedDate(), - subtitleText = SystemMonitorHelper.getCalendarSubtitle() - ) - // Pos 3: NETWORK - bookmarkItems[3] = bookmarkItems[3].copy( - title = netInfo.first, - subtitleText = netInfo.second - ) - // Pos 4: RAM - bookmarkItems[4] = bookmarkItems[4].copy( - title = "RAM: $ramUsedStr / $ramTotalStr (${ramStats.usedPercent}%)", - subtitleText = "⚡ Click to Boost RAM" - ) - // Pos 5: STORAGE - bookmarkItems[5] = bookmarkItems[5].copy( - title = "Disk: $storageFreeStr Free", - subtitleText = "Used: $storageUsedStr / $storageTotalStr (${storageStats.usedPercent}%)" - ) - - if (!rvBookmarks.isComputingLayout) { - bookmarksAdapter?.notifyItemRangeChanged(1, 5, "STATS_PAYLOAD") + if (!isShown) return + val scope = statsScope ?: return + if (statsJob?.isActive == true) return + + tickCounter++ + val sampleStorage = tickCounter % STORAGE_TICK_EVERY == 0L + + statsJob = scope.launch { + val snapshot = withContext(Dispatchers.Default) { + SystemMonitorHelper.updateNetworkSpeed() + val net = SystemMonitorHelper.getNetworkInfo(context) + val ram = SystemMonitorHelper.getRamStats(context) + val storage = if (sampleStorage) { + val stats = SystemMonitorHelper.getStorageStats() + Triple( + SystemMonitorHelper.formatBytes(stats.freeBytes), + SystemMonitorHelper.formatBytes(stats.usedBytes), + stats.usedPercent + ) + } else null + DashboardSnapshot( + timeTitle = SystemMonitorHelper.getFormattedTime(), + timeSubtitle = SystemMonitorHelper.getTimeSubtitle(), + dateTitle = SystemMonitorHelper.getFormattedDate(), + dateSubtitle = SystemMonitorHelper.getCalendarSubtitle(), + networkTitle = net.first, + networkSubtitle = net.second, + ramTitle = "RAM: ${SystemMonitorHelper.formatBytes(ram.usedBytes)} / " + + "${SystemMonitorHelper.formatBytes(ram.totalBytes)} (${ram.usedPercent}%)", + ramPercent = ram.usedPercent, + storageFree = storage?.first ?: "", + storageUsed = storage?.second ?: "", + storagePercent = storage?.third ?: -1 + ) + } + if (!isShown || !::rvBookmarks.isInitialized) return@withContext + applyDashboardSnapshot(snapshot) } } catch (e: Throwable) { // Ignore any transient update errors } } + private fun applyDashboardSnapshot(snapshot: DashboardSnapshot) { + if (bookmarkItems.size < 6) return + // Pos 1: TIME, 2: CALENDAR, 3: NETWORK, 4: RAM, 5: STORAGE + bookmarkItems[1] = bookmarkItems[1].copy( + title = snapshot.timeTitle, + subtitleText = snapshot.timeSubtitle + ) + bookmarkItems[2] = bookmarkItems[2].copy( + title = snapshot.dateTitle, + subtitleText = snapshot.dateSubtitle + ) + bookmarkItems[3] = bookmarkItems[3].copy( + title = snapshot.networkTitle, + subtitleText = snapshot.networkSubtitle + ) + bookmarkItems[4] = bookmarkItems[4].copy( + title = snapshot.ramTitle, + subtitleText = "Tap for memory & storage details" + ) + if (snapshot.storageFree.isNotEmpty()) { + bookmarkItems[5] = bookmarkItems[5].copy( + title = "Disk: ${snapshot.storageFree} Free", + subtitleText = "Used: ${snapshot.storageUsed} (${snapshot.storagePercent}%)" + ) + } + if (!rvBookmarks.isComputingLayout) { + bookmarksAdapter?.notifyItemRangeChanged(1, 5, "STATS_PAYLOAD") + } + } + + private data class DashboardSnapshot( + val timeTitle: String, + val timeSubtitle: String, + val dateTitle: String, + val dateSubtitle: String, + val networkTitle: String, + val networkSubtitle: String, + val ramTitle: String, + val ramPercent: Int, + val storageFree: String, + val storageUsed: String, + val storagePercent: Int + ) + override fun onAttachedToWindow() { super.onAttachedToWindow() + if (statsScope == null) { + statsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + } startDashboardTicker() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() stopDashboardTicker() + statsScope?.cancel() + statsScope = null } override fun onWindowVisibilityChanged(visibility: Int) { diff --git a/app/src/main/java/com/gothwad/browser/activity/main/view/search/SearchOverlayView.kt b/app/src/main/java/com/gothwad/browser/activity/main/view/search/SearchOverlayView.kt index 00690f19..e3083f4e 100644 --- a/app/src/main/java/com/gothwad/browser/activity/main/view/search/SearchOverlayView.kt +++ b/app/src/main/java/com/gothwad/browser/activity/main/view/search/SearchOverlayView.kt @@ -72,10 +72,6 @@ class SearchOverlayView @JvmOverloads constructor( onVoiceSearchRequested?.invoke() } - vb.ibSearchLens.setOnClickListener { - onVoiceSearchRequested?.invoke() - } - vb.etSearchInput.setOnEditorActionListener { _, actionId, event -> if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_GO || @@ -123,6 +119,16 @@ class SearchOverlayView @JvmOverloads constructor( imm?.hideSoftInputFromWindow(vb.etSearchInput.windowToken, 0) visibility = View.GONE vb.etSearchInput.clearFocus() + // Hand focus back to the page (or the home grid). Leaving focus on a view that is now + // GONE strands it: D-pad, Enter and tab-switch keys stop working until the user taps twice. + post { + val parent = (context as? android.app.Activity)?.findViewById(R.id.flWebViewContainer) + if (parent != null && parent.isShown) { + parent.requestFocus() + } else { + (context as? android.app.Activity)?.findViewById(R.id.ibHome)?.requestFocus() + } + } } private fun performNavigation(query: String) { @@ -190,10 +196,20 @@ class SearchSuggestionsAdapter( private val items = mutableListOf() + /** + * Precise range updates instead of notifyDataSetChanged(): a full rebind of the suggestion + * list flickers and drops D-pad focus while the user is still typing (AGENTS.md rule 5). + */ fun submitList(newItems: List) { + val oldSize = items.size items.clear() items.addAll(newItems) - notifyDataSetChanged() + if (oldSize > 0) { + notifyItemRangeRemoved(0, oldSize) + } + if (newItems.isNotEmpty()) { + notifyItemRangeInserted(0, newItems.size) + } } class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { diff --git a/app/src/main/java/com/gothwad/browser/settings/MainSettingsView.kt b/app/src/main/java/com/gothwad/browser/settings/MainSettingsView.kt index 4b34bbe2..c15893a7 100644 --- a/app/src/main/java/com/gothwad/browser/settings/MainSettingsView.kt +++ b/app/src/main/java/com/gothwad/browser/settings/MainSettingsView.kt @@ -31,6 +31,7 @@ class MainSettingsView @JvmOverloads constructor( SettingsGeneralSection.initDefaultBrowserUI(context, vb, onDismissDialog, activity) SettingsDisplaySection.initDisplayAndZoomSettingsUI(context, vb, config, onDismissDialog, activity) SettingsEngineSection.initWebBrowserEngineSettingsUI(context, vb, config, activity) + SettingsEngineSection.initSafeBrowsingUI(context, vb, config) SettingsEngineSection.initHomePageAndSearchEngineConfigUI(context, vb, config, settingsModel) SettingsEngineSection.initUAStringConfigUI(context, vb, config, settingsModel) SettingsEngineSection.initAdBlockConfigUI(context, vb, config, adblockModel, activity) diff --git a/app/src/main/java/com/gothwad/browser/settings/SettingsEngineSection.kt b/app/src/main/java/com/gothwad/browser/settings/SettingsEngineSection.kt index 2dfe4026..732b8f2a 100644 --- a/app/src/main/java/com/gothwad/browser/settings/SettingsEngineSection.kt +++ b/app/src/main/java/com/gothwad/browser/settings/SettingsEngineSection.kt @@ -70,6 +70,27 @@ object SettingsEngineSection { .show() } + fun initSafeBrowsingUI( + context: Context, + vb: ViewSettingsMainBinding, + config: Config + ) { + vb.llSafeBrowsing.setOnLongClickListener { + android.widget.Toast.makeText(context, R.string.safe_browsing_desc, android.widget.Toast.LENGTH_LONG).show() + true + } + vb.scSafeBrowsing.isChecked = config.safeBrowsingEnabled + vb.llSafeBrowsing.setOnClickListener { vb.scSafeBrowsing.toggle() } + vb.scSafeBrowsing.setOnCheckedChangeListener { _, isChecked -> + config.safeBrowsingEnabled = isChecked + android.widget.Toast.makeText( + context, + "Safe browsing applies to newly opened tabs", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + fun initHomePageAndSearchEngineConfigUI( context: Context, vb: ViewSettingsMainBinding, diff --git a/app/src/main/java/com/gothwad/browser/settings/SettingsRemoteSection.kt b/app/src/main/java/com/gothwad/browser/settings/SettingsRemoteSection.kt index efd02e95..77bb0034 100644 --- a/app/src/main/java/com/gothwad/browser/settings/SettingsRemoteSection.kt +++ b/app/src/main/java/com/gothwad/browser/settings/SettingsRemoteSection.kt @@ -155,7 +155,7 @@ object SettingsRemoteSection { val nameTv = TextView(context).apply { text = dev.name - setTextColor(Color.WHITE) + setTextColor(ContextCompat.getColor(context, R.color.google_text_primary)) textSize = 14f typeface = android.graphics.Typeface.DEFAULT_BOLD } diff --git a/app/src/main/java/com/gothwad/browser/webengine/webview/WebViewEx.kt b/app/src/main/java/com/gothwad/browser/webengine/webview/WebViewEx.kt index d5ca201f..d662717e 100644 --- a/app/src/main/java/com/gothwad/browser/webengine/webview/WebViewEx.kt +++ b/app/src/main/java/com/gothwad/browser/webengine/webview/WebViewEx.kt @@ -132,11 +132,17 @@ open class WebViewEx(context: Context, val callback: Callback, val jsInterface: allowContentAccess = true cacheMode = WebSettings.LOAD_DEFAULT setRenderPriority(WebSettings.RenderPriority.HIGH) + // Chrome keeps Safe Browsing on; it was previously pinned to `false` while the + // README advertised "securest browser". Now it follows a real setting (default on). if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - safeBrowsingEnabled = false + safeBrowsingEnabled = config.safeBrowsingEnabled } + // Offscreen pre-rasterization is only worth its memory on the tab the user is + // actually looking at. Keeping it on for every background tab cost an extra painted + // buffer per tab, which is exactly what 1-2 GB TV boxes cannot spare; it is toggled + // with attach/detach below. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - offscreenPreRaster = true + offscreenPreRaster = false } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE @@ -408,6 +414,26 @@ open class WebViewEx(context: Context, val callback: Callback, val jsInterface: return super.onTouchEvent(event) } + /** + * Only the visible tab pre-rasters. `TabsModel` keeps several tab WebViews alive at once, so + * `offscreenPreRaster` used to allocate a paint buffer for each of them; on low-RAM TV boxes + * that extra memory was the difference between smooth tab switching and the renderer being + * killed. + */ + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + settings.offscreenPreRaster = true + } + } + + override fun onDetachedFromWindow() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + settings.offscreenPreRaster = false + } + super.onDetachedFromWindow() + } + override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { super.onScrollChanged(l, t, oldl, oldt) val dy = t - oldt diff --git a/app/src/main/java/com/gothwad/browser/webengine/webview/WebViewWebEngine.kt b/app/src/main/java/com/gothwad/browser/webengine/webview/WebViewWebEngine.kt index 1c53a97e..def750de 100644 --- a/app/src/main/java/com/gothwad/browser/webengine/webview/WebViewWebEngine.kt +++ b/app/src/main/java/com/gothwad/browser/webengine/webview/WebViewWebEngine.kt @@ -286,7 +286,18 @@ class WebViewWebEngine(val tab: WebTabState) : WebEngine, CursorDrawerDelegate.C WebEngineFactory.registerProvider(WebEngineProvider("WebView", object : WebEngineProviderCallback { override suspend fun initialize(context: Context, webViewContainer: CursorLayout) {} override fun createWebEngine(tab: WebTabState): WebEngine = WebViewWebEngine(tab) - override suspend fun clearCache(ctx: Context) { WebView(ctx).clearCache(true) } + override suspend fun clearCache(ctx: Context) = withContext(Dispatchers.Main) { + // The on-disk cache is shared per app, so a throwaway WebView is the only + // documented way to clear it for every live tab. It must be destroyed + // afterwards: previously each "Clear cache" leaked a whole renderer instance + // (tens of MB), on exactly the low-RAM devices this setting exists for. + val wv = WebView(ctx) + try { + wv.clearCache(true) + } finally { + wv.destroy() + } + } override fun onThemeSettingUpdated(value: Config.Theme) {} override fun getWebEngineVersionString(): String { val pkg = WebViewCompat.getCurrentWebViewPackage(AppContext.get()) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index fefd57d0..d4b044b6 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -274,14 +274,14 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_below="@+id/rlActionBar" - android:layout_alignParentBottom="true" /> + android:layout_above="@+id/llBottomPanel" /> @@ -290,6 +290,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_below="@+id/rlActionBar" + android:layout_above="@+id/llBottomPanel" android:visibility="gone" android:scaleType="fitXY" tools:ignore="ContentDescription" /> @@ -302,6 +303,7 @@ android:layout_height="match_parent" android:scaleType="centerInside" android:layout_below="@+id/rlActionBar" + android:layout_above="@+id/llBottomPanel" android:layout_alignWithParentIfMissing="true" android:visibility="gone"> -