diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
new file mode 100644
index 00000000..afe7f8d3
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,116 @@
+name: Quality Gate & Tests
+
+# The release pipeline (build_apk.yml) only runs on main and only builds. This workflow is the
+# cheap, always-on half: the mechanical AGENTS.md rules, the JVM unit tests, and a compile so a
+# PR can never land something that does not even build.
+
+on:
+ pull_request:
+ push:
+ branches: [ main, master ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: quality-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ rules:
+ name: AGENTS.md rules (grep gate)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # No JDK, no SDK: this is the feedback loop that should answer in seconds.
+ - name: Run quality gate
+ run: bash scripts/quality-gate.sh
+
+ test:
+ name: Unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Make Gradle Wrapper Executable
+ run: chmod +x ./gradlew
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/main' }}
+
+ - name: Unit tests (app + common)
+ run: |
+ set -o pipefail
+ ./gradlew :app:common:testDebugUnitTest testDebugUnitTest --no-daemon --continue --stacktrace 2>&1 | tee gradle.log
+
+ # The raw log lives behind an endpoint a sandboxed runner often cannot reach, and a red X
+ # with no message teaches nobody anything: put the useful lines in the job summary.
+ - name: Publish the failure in the job summary
+ if: failure()
+ run: bash scripts/ci-summarize-gradle.sh
+
+ build:
+ name: Compile (debug) + Android lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Make Gradle Wrapper Executable
+ run: chmod +x ./gradlew
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/main' }}
+
+ # debug builds are wired to debug.keystore in app/build.gradle.kts; generate the standard
+ # debug key when the repo copy is absent, same as build_apk.yml does.
+ - name: Ensure debug keystore exists
+ run: |
+ if [ ! -f debug.keystore ]; then
+ keytool -genkey -v -keystore debug.keystore \
+ -storepass android -keypass android \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -dname "CN=Android Debug,O=Android,C=US" \
+ -alias androiddebugkey
+ fi
+
+ - name: Compile debug
+ run: |
+ set -o pipefail
+ ./gradlew :app:compileDebugKotlin :app:common:compileDebugKotlin --no-daemon --continue --stacktrace 2>&1 | tee gradle.log
+
+ - name: Publish the failure in the job summary
+ if: failure()
+ run: bash scripts/ci-summarize-gradle.sh
+
+ - name: Android lint
+ continue-on-error: true
+ run: ./gradlew :app:lintDebug --no-daemon
+
+ - name: Upload lint report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: lint-report
+ path: app/build/reports/lint-results-*.html
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index b45a0883..8bdeab49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,9 @@
*.keystore
!debug.keystore
*.hprof
-/buildSrc/build
\ No newline at end of file
+/buildSrc/build
+
+/.kotlin
+/local.properties
+/app/build
+/app/common/build
diff --git a/.kotlin/sessions/kotlin-compiler-1692686420435008840.salive b/.kotlin/sessions/kotlin-compiler-1692686420435008840.salive
deleted file mode 100644
index e69de29b..00000000
diff --git a/.kotlin/sessions/kotlin-compiler-6720395899264318193.salive b/.kotlin/sessions/kotlin-compiler-6720395899264318193.salive
deleted file mode 100644
index e69de29b..00000000
diff --git a/AGENTS.md b/AGENTS.md
index fc4a7443..0faad1cd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -109,7 +109,7 @@ If a genuine device-specific or OS-level bug is discovered that cannot be fixed
These are not style preferences. Each one maps to a real, previously-shipped bug (see §5 for the full incident log). Violating any of these is very likely to reintroduce a fixed bug.
-1. **Never inject custom CSS/JS to fake a native browser feature** (zoom, dark mode, layout scaling, etc.) if WebView/Chromium already exposes a native API for it. Use `zoomIn()`/`zoomOut()`/`zoomBy()` for zoom (already enabled via `setSupportZoom`/`builtInZoomControls`), not CSS `zoom` injection. Use WebView's native `prefers-color-scheme`/theme handling, not forced algorithmic darkening.
+1. **Never inject custom CSS/JS to fake a native browser feature** (zoom, dark mode, layout scaling, etc.) if WebView/Chromium already exposes a native API for it. Use WebView's native `prefers-color-scheme`/theme handling, not forced algorithmic darkening; never patch page prototypes (`HTMLMediaElement.prototype`), never synthesize duplicate DOM events, never match obfuscated site class names. Zoom is the one documented exception and is *only* allowed through `PageZoomController` (see rule 15) — no other file may inject layout/CSS.
2. **Never convert a real input event into a synthetic event of a different type** (e.g., mouse `MotionEvent` → synthetic touch `MotionEvent`). Deliver events to WebView exactly as the OS reports them. If a specific device has a broken input driver, that is an OS/OEM-level bug outside this app's ability to safely work around without breaking other devices — do not add conversion/interception logic to compensate for it.
3. **Never add device-, chipset-, OEM-, or box-brand-specific logic anywhere.** All input, rendering, and performance fixes must be generic and based on documented, standard Android/WebView APIs. If a bug only reproduces on one brand of set-top box, investigate whether it's actually a general bug being *masked* on other devices (it usually is) before assuming it needs device-specific handling.
4. **Every WebView instance must have `onRenderProcessGone` handled**, recovering gracefully (recreate WebView + reload for the foreground tab, silent state-clear for background tabs) rather than letting a renderer crash take down the whole app.
@@ -121,7 +121,13 @@ 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.
+16. **The native home screen is an overlay, never a navigation.** `showHomeScreen()` must not call `clearHistory()` or `loadUrl("about:blank")`: the tab keeps its document and back/forward list, playback is paused, and `hideHomeScreen()` reveals it again. "On home" is answered only via `isHomeUrl()` / `isHomeScreenShown()`, and `WebTabState.lastUrlBeforeHome` means "the page currently covered", never a fake forward entry.
+17. **All user-visible text and every colour must come from resources.** No `android:text`/`contentDescription`/`hint` literal in a layout, no `Toast.makeText(ctx, "...")`/`setMessage("...")` literal, no hex colour in a layout, drawable or `.kt` file. Colours come from the semantic palette in `values/colors.xml` + `values-night/colors.xml` (day *and* night), so a light theme can never end up with white-on-white text; text that shows a count uses `plurals`. `scripts/quality-gate.sh` (run by `.github/workflows/quality.yml`) enforces this mechanically — do not "fix" a gate by deleting the rule, mark a deliberate exception with `quality-gate: allow` and say why.
+18. **Input devices are handled through the platform only, and one `Config.InputMode` decides the coherent bundle** (virtual cursor on/off, joystick-axis D-pad translation, desktop-view default). Never patch page JS, synthesize events, or branch on a device brand to make a remote or a mouse work; if a mode is added, it must map onto existing native flags and be re-applied when devices come and go.
+
+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.**
---
@@ -163,9 +169,26 @@ Keep this list updated whenever a significant bug is found and fixed. This is th
| 14 | Certain interactive web elements (dropdowns/buttons in JS-heavy sites) failed to register mouse clicks on some devices, while scrolling and simple clicks worked fine | Root cause not fully isolated at the app level — evidence pointed at a system/driver-level input-delivery quirk on specific hardware (out of this app's control), compounded by the now-removed synthetic-touch-conversion hack which was an incomplete attempt to work around it | The device-specific workaround was removed per the "no jugaad" principle (see §3, rule 3); any remaining device-specific click issues are treated as a platform/OEM limitation, not something to special-case in app code | 🟡 Accepted limitation — do not reintroduce device-specific workarounds for this |
| 15 | Websites rendered with visibly wrong colors (inverted/dark navy backgrounds, discolored images) | Chromium's "Algorithmic Darkening"/Force Dark was applied automatically whenever the device's system UI was in night/dark mode, defaulted to enabled | Removed entirely — websites now always render their true, unmodified colors regardless of system theme | ✅ Fixed |
| 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 |
+| 17 | `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 | `utils/ListDiff.kt` (shared DiffUtil helper with move-detection disabled above 200 rows) now backs the favourites / downloads / tabs sidebars and the Chrome tab grid. The only remaining `notifyDataSetChanged()` calls in the app are on `BaseAdapter`/`ArrayAdapter` (ListView) classes — `HistoryAdapter`, `DownloadListAdapter`, `FavoritesListAdapter`, `ShortcutsSettingsView` — where no finer API exists; rule 5 is about `RecyclerView.Adapter` and those are exempt by design. Enforced by `scripts/quality-gate.sh` | ✅ Fixed |
| 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). Every layout, drawable and Kotlin colour literal in the app now resolves through the semantic palette in `values/colors.xml` + `values-night/colors.xml` (`app_surface_elevated`, `app_surface_sunken`, `app_on_surface`, `app_on_surface_muted`, `app_outline`, `app_accent`, `app_error`, `app_warning`, `app_success`, `app_on_accent`). Focus rings on `bg_card_sleek` / `bg_card_tab_selector` were the same colour as the fill — invisible on a TV — and are now accent strokes. A grep gate keeps it that way | ✅ 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 |
+| 28 | The app lock was theatre: the PIN was stored in **plaintext** (twice — `desktop_mode` prefs key `app_lock_pin` and `files/app_lock_pin.dat`), any 4-digit guess was accepted forever after the first unlock, and there was no throttle on guessing | `verifyPin()` compared a stored string; `isSessionUnlocked()` only checked that a token file *existed* (`AppLockActivity.onCreate` wrote it whenever no unlock was required), so the file never expired | `AppLockManager` rewritten: PBKDF2-HmacSHA256 (25 000 iterations, 16-byte `SecureRandom` salt) stored as `pbkdf2:iters:salt:hash`, constant-time compare, lockout after 5 failures (15 s base, doubling, capped at 15 min), the session token now stores an **expiry** (60 s grace), legacy plaintext records are upgraded on the next successful unlock and the plaintext file is deleted. `AppLockActivity`/`TvPinDialog` show the lockout countdown | ✅ Fixed |
+| 29 | App-lock notifications and the keep-alive/download notifications rendered as a grey blob on Android 8+ | `setSmallIcon(R.mipmap.ic_launcher)` — a launcher mipmap is not a valid small icon (must be a monochrome drawable) | `DownloadService` uses `ic_file_download_grey_900`, `BrowserKeepAliveService` uses `ic_stat_network` | ✅ Fixed |
+| 30 | One accidental press of the Home button cost a full reload of the page and destroyed its history | `showHomeScreen()` called `webEngine.clearHistory()` + `loadUrl("about:blank")`, and the "forward" button was a fake entry that re-fetched `lastUrlBeforeHome` over the network | Home is now an **overlay** on top of the live page: playback is paused, the WebView keeps its document and its back/forward list, and `hideHomeScreen()` reveals it again. `navigateInternal()` likewise stopped clearing history when leaving Home; the fake forward entry is gone and `navigateBackInternal()` no longer carries an ignored `goHomeIfNoHistory` flag. `isHomeUrl()` / `isHomeScreenShown()` are now the single definitions of "on home" | ✅ Fixed |
+| 31 | "Desktop site" was one global switch: toggling it in a menu reloaded *every* live tab, and three menu entry points each had their own copy of that loop | `Config.desktopMode` was both the per-tab state and the app default; `ChromeMenuPopup`, `WebsiteMenuPopup`, `BrowserSidebarPopup` each applied it to all tabs | `WebTabState.desktopMode: Boolean?` (per-tab override, `null` = follow the default, persisted via Room v22 migration `ALTER TABLE tabs ADD COLUMN desktop_mode`), `WebViewEx.desktopModeOverride` so UA/zoom follow the *displayed* tab, and one shared implementation in `activity/main/DesktopMode.kt` that all three menus call. Settings → User Agent & Identity gained the real "desktop by default" switch | ✅ Fixed |
+| 32 | Pointer/layout behaviour was scattered over four settings sub-screens; keyboard+mouse mode drew a second cursor over the real one; nothing reacted to a mouse being plugged in | `enableVirtualCursor` / `disableMotionAxesDpadNavigation` / `desktopMode` were independent toggles with no coherence, and no mode concept existed | `Config.InputMode` (`AUTO`/`TV_REMOTE`/`KEYBOARD_MOUSE`/`TOUCH`) with a segmented switch at the top of Settings, resolved against `Utils.isTV()` + `HardwareInputManager.isPhysicalMouseConnected()`, re-applied on every resume and on device add/remove. It writes only existing native flags — no new mechanism | ✅ Fixed |
+| 33 | The home screen's "⚡ Click to Boost RAM" card promised a RAM boost and reported `System.gc()`'s delta as memory "freed" | A gimmick: `performRamBoost()` measured free RAM before/after `gc()`, which the OS may or may not honour, and the UI presented it as a feature | `performRamBoost()` deleted. The card opens a real memory panel (device RAM, this app's heap, live page views vs the adaptive cap) whose two actions are real: release detached background page views (`TabsModel.onTrimMemory()`) and clear the WebView cache. Rule 14 extended accordingly | ✅ Fixed |
+| 34 | `generic_injects.js` patched `HTMLMediaElement.prototype.playing` globally, and a "TV precision click engine" synthesised a second `click()` on every pointer-up while matching Google's obfuscated class names (`gb_d`, `gb_wa`) | Page-level workarounds for input quirks — exactly what rules 1/2/3 forbid; the synthetic click risked double-activating controls on every site | Both removed. Media keys use local helpers and drive the element that is actually playing (with the autoplay-promise rejection swallowed); clicks are left to the platform. If a device really drops clicks, the fix belongs in the native input path, not in page JS | ✅ Fixed |
+| 35 | Almost no UI text was translatable and section headers were emoji-decorated: ~300 `android:text`/`contentDescription` literals across 64 layouts, 44 toasts/dialog titles in Kotlin, "🖥️ Display & UI Scaling"-style headers, plus a settings search that matched hard-coded English keyword blobs | UI text was typed inline as screens were added | Everything moved to `res/values/strings.xml` (560+ entries, existing strings reused instead of duplicated), counts converted to `plurals`, emoji stripped from labels and `string-array` items, and `SettingsDialog`/`openMainCategory` now take `@StringRes` ids so a row label and its detail header cannot drift apart | ✅ Fixed |
---
@@ -183,7 +206,12 @@ Because this app has a history of one fix quietly breaking another area, treat t
- [ ] Zoom in/out on a site with a fixed-position dropdown/menu (e.g. google.com's account menu) — menu stays correctly positioned at every zoom level.
- [ ] 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).
+- [ ] No new `runBlocking`, synchronous I/O on main/WebView threads, or `notifyDataSetChanged()` introduced by the change — run `bash scripts/quality-gate.sh` (it also checks hard-coded text/colours and dangling `@string`/`@color` references; CI runs the same script).
+- [ ] Home button on a real page, then Back: the page must still be loaded (no reload, no lost scroll position), and Back/Forward must both behave.
+- [ ] "Desktop site" toggled in one tab while another tab shows a different site: only that tab changes, and the other tab keeps its layout/zoom.
+- [ ] Settings → input mode: switch between remote / keyboard+mouse / touch and check the pointer, D-pad focus and page identity all follow it on both TV and phone.
+- [ ] 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/README.md b/README.md
index 9d4af7f5..c2f98c13 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
# 🌐 Gothwad TV Browser
### *The Ultimate Desktop-Grade Web Browser for Android TVs, Tablets, Phones, Linux, Windows & macOS*
-[](https://github.com/Gothwad/gothwad-browser)
+[](https://github.com/GothwadTech/browser)
[](https://developer.android.com/develop/ui/views/layout/webapps/webview)
[](https://kotlinlang.org)
[](#-engineering-journey--vision)
@@ -17,13 +17,23 @@
[](https://play.google.com/store/apps/details?id=com.gothwad.browser)
[](https://www.jio.com/fiber/services/set-top-box)
[](https://gothwad.com)
-[](https://github.com/Gothwad/gothwad-browser/releases)
+[](https://github.com/GothwadTech/browser/releases)
> **"Gothwad Browser is the fastest, securest, and most lightweight browser for Android Phones, TVs, Linux, Windows, and macOS you have ever seen. Engineered with full keyboard and mouse parity, industry-leading TV remote D-Pad navigation, an inbuilt File Manager, TV Notepad, AdBlocker, and Clipboard Manager. Try first, then believe."**
> — *Made in India with ❤️ by Gothwad Tech*
+> **Status note (2026-10-14).** The app is in a pre-release hardening pass. Claims below are being
+> converted one by one from "aspirational" into "measured": the APK-size figure is a **target**, not a
+> CI measurement yet, and the 10+ language claim is qualified under *Global Accessibility* below.
+> **Do not publish a build before the licence/attribution question in
+> [`docs/AUDIT-2026-10-14.md`](docs/AUDIT-2026-10-14.md) §0 is settled**: this tree derives from the
+> GPL-3.0-or-later *TV Bro* project, which cannot be reconciled with the "All Rights Reserved /
+> NO OPEN SOURCE LICENSE IS GRANTED" notice below while the in-app About screen advertises "100% Open
+> Source Software" — and the repo carries no `LICENSE` file at all. Settling that is the owner's
+> decision, not a code fix; nothing here changes the licence terms.
+
---
@@ -73,12 +83,12 @@
Gothwad Browser is built with a singular mission: **bring desktop PC Chrome-grade browsing to Android TV, mobile screens, Linux, Windows, and macOS without the bloat.**
-- ⚡ **Ultra-Fast & Featherweight:** Engineered to stay under a **10 MB APK footprint**, booting instantaneously even on budget TV streaming sticks with limited RAM (1GB/2GB).
+- ⚡ **Ultra-Fast & Featherweight:** Engineered around a **sub-10 MB APK target**, with the live-page cap, snapshot thumbnails and cache behaviour sized for budget TV streaming sticks (1–2 GB RAM, slow eMMC). The size figure is a target, not a measured CI number yet.
- 🖥️ **PC-Grade Chromium Engine:** Native Chromium rendering fidelity. No synthetic CSS zooms, no layout reflow compromises, and no artificial color shifts—pages render exactly as intended.
- 🛡️ **Integrated Ad & Tracker Protection:** Fast, real-time filter-list ad blocking (Brave `AdBlockClient` engine) directly intercepting network resources asynchronously without degrading render performance.
- 🗂️ **Advanced Tab Architecture:** Dynamic top-tab bar and row/grid tab switchers with proactive LRU memory trimming so you can keep 10+ tabs without background crashes.
-- 🔒 **App Lock & Privacy:** Built-in PIN security lock to safeguard your browsing sessions, private files, notes, and history.
-- 🌐 **Global Accessibility:** Native localized interfaces across 10+ languages with responsive 10-foot TV UI ergonomics.
+- 🔒 **App Lock & Privacy:** PIN lock (PBKDF2-hashed, never stored in plaintext, with attempt lockout and an expiring unlock session), Chromium Safe Browsing **enabled by default**, incognito tabs that never hit disk, and a clipboard manager that respects it.
+- 🌐 **Global Accessibility:** Responsive 10-foot TV UI ergonomics, with translated string resources for **11 locales** (de, fa, it, iw, pl, ru, uk, vi, zh, zh-TW + English default). Coverage is currently partial (~150 of ~660 strings per locale): every UI label now lives in `values/strings.xml`, so translations can be added file-by-file instead of chasing hard-coded text — new strings land in English first and are translated on request.
---
@@ -87,7 +97,7 @@ Gothwad Browser is built with a singular mission: **bring desktop PC Chrome-grad
### 📱 1. Android (TV, Phones & Tablets)
1. **Google Play Store / JioStore:** Search for **"Gothwad TV Browser"** and install with 1-click.
2. **Direct APK Side-loading:**
- - Download `GothwadTVBrowser-vX.X.X-Release.apk` from [GitHub Releases](https://github.com/Gothwad/gothwad-browser/releases).
+ - Download `GothwadTVBrowser-vX.X.X-Release.apk` from [GitHub Releases](https://github.com/GothwadTech/browser/releases).
- Use a file manager, USB drive, or "Send Files to TV" app to install the APK directly on your TV box or phone.
---
@@ -97,7 +107,7 @@ Gothwad Browser is built with a singular mission: **bring desktop PC Chrome-grad
#### For Debian / Ubuntu / Mint (`.deb`):
```bash
# 1. Download the .deb from Releases
-wget https://github.com/Gothwad/gothwad-browser/releases/latest/download/gothwad-browser_all.deb
+wget https://github.com/GothwadTech/browser/releases/latest/download/gothwad-browser_all.deb
# 2. Install package
sudo dpkg -i gothwad-browser_all.deb
@@ -116,7 +126,7 @@ cd gothwad-browser-linux-*
### 🪟 3. Windows (10 & 11)
-1. Download **`gothwad-browser-windows-vX.X.X.zip`** from [GitHub Releases](https://github.com/Gothwad/gothwad-browser/releases).
+1. Download **`gothwad-browser-windows-vX.X.X.zip`** from [GitHub Releases](https://github.com/GothwadTech/browser/releases).
2. Extract the folder on your PC.
3. Double-click **`Install.bat`**.
- It will automatically create a **Desktop shortcut** and **Start Menu entry** with the official browser icon.
@@ -128,7 +138,7 @@ cd gothwad-browser-linux-*
### 🍎 4. macOS (Apple Silicon & Intel)
-1. Download **`gothwad-browser-macos-vX.X.X.zip`** from [GitHub Releases](https://github.com/Gothwad/gothwad-browser/releases).
+1. Download **`gothwad-browser-macos-vX.X.X.zip`** from [GitHub Releases](https://github.com/GothwadTech/browser/releases).
2. Extract the archive.
3. Drag **`Gothwad TV Browser.app`** into your **`/Applications`** folder (or run `./install.sh`).
4. Launch via **Launchpad**, **Spotlight**, or your **Dock**.
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..a2b4167b 100644
--- a/app/common/src/main/java/com/gothwad/browser/Config.kt
+++ b/app/common/src/main/java/com/gothwad/browser/Config.kt
@@ -76,13 +76,19 @@ 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
* fast tab switching without exhausting system memory.
*/
const val DEFAULT_MAX_LIVE_TABS = 6
+ /** Cap for the tab-switcher snapshot width; the grid cards are far smaller than a screen. */
+ const val THUMBNAIL_MAX_WIDTH_PX = 480
const val DESKTOP_MODE_KEY = "desktop_mode"
+ /** Which input device the user drives the browser with - see [InputMode]. */
+ const val INPUT_MODE_KEY = "input_mode"
const val DESKTOP_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
const val ENGINE_WEB_VIEW = "WebView"
@@ -117,6 +123,23 @@ class Config(val prefs: SharedPreferences) {
BLACK_MIDNIGHT;
}
+ /**
+ * How the user drives the browser. This is the one setting that decides pointer handling and
+ * the default page layout, so that TV users do not have to dig through four different
+ * sub-screens (virtual cursor, joystick axes, user agent) to get a working remote or a working
+ * mouse.
+ *
+ * - [AUTO] - resolve from the device: touch screen -> [TOUCH], TV with a mouse plugged in ->
+ * [KEYBOARD_MOUSE], anything else -> [TV_REMOTE].
+ * - [TV_REMOTE] - D-pad only: the DPAD-driven virtual cursor and axis navigation are on.
+ * - [KEYBOARD_MOUSE] - a real pointer drives the page, so the drawn virtual cursor is turned
+ * off (two pointers on screen at once was the classic "double cursor" complaint).
+ * - [TOUCH] - phone / tablet: no virtual cursor, no axis-to-DPAD translation.
+ */
+ enum class InputMode {
+ AUTO, TV_REMOTE, KEYBOARD_MOUSE, TOUCH
+ }
+
enum class HomePageMode {
HOME_PAGE, SEARCH_ENGINE, CUSTOM, BLANK
}
@@ -129,7 +152,9 @@ class Config(val prefs: SharedPreferences) {
get() = prefs.getBoolean(INCOGNITO_MODE_KEY, false)
@SuppressLint("ApplySharedPref")
set(value) {
- prefs.edit().putBoolean(INCOGNITO_MODE_KEY, value).commit()
+ // The incognito process reads this flag, so it has to hit disk before the other
+ // process is started - apply() would race with it.
+ prefs.edit().putBoolean(INCOGNITO_MODE_KEY, value).commit() // quality-gate: allow
}
var incognitoModeHintSuppress: Boolean
@@ -197,6 +222,26 @@ class Config(val prefs: SharedPreferences) {
prefs.edit().putBoolean(ENABLE_VIRTUAL_CURSOR_KEY, value).apply()
}
+ /** Stored input-mode choice (`AUTO` by default, i.e. "follow the device"). */
+ var inputMode: InputMode
+ get() = prefs.getString(INPUT_MODE_KEY, null)?.let { saved ->
+ InputMode.entries.firstOrNull { it.name == saved }
+ } ?: InputMode.AUTO
+ set(value) {
+ prefs.edit().putString(INPUT_MODE_KEY, value.name).apply()
+ }
+
+ /** [InputMode.AUTO] resolved against the actual device / connected hardware. */
+ fun resolveInputMode(isTvDevice: Boolean, physicalMouseConnected: Boolean): InputMode =
+ when (inputMode) {
+ InputMode.AUTO -> when {
+ !isTvDevice -> InputMode.TOUCH
+ physicalMouseConnected -> InputMode.KEYBOARD_MOUSE
+ else -> InputMode.TV_REMOTE
+ }
+ else -> inputMode
+ }
+
var cursorSizePercent: Int
get() = prefs.getInt(CURSOR_SIZE_PERCENT_KEY, 100)
.coerceIn(CURSOR_SIZE_PERCENT_MIN, CURSOR_SIZE_PERCENT_MAX)
@@ -332,6 +377,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)
@@ -420,12 +482,43 @@ class Config(val prefs: SharedPreferences) {
var showTopTabBar = ObservableBooleanPreference(false, SHOW_TOP_TAB_BAR_KEY)
var keepAliveInBackground = ObservableBooleanPreference(true, KEEP_ALIVE_IN_BACKGROUND_KEY)
+ /**
+ * Cap used when the user never set one. Lowered once at startup by [applyMemoryAwareDefaults]:
+ * a 1 GB Android TV box cannot keep six renderers alive, since every extra live WebView is a
+ * full native process plus a painted buffer.
+ */
+ @Volatile
+ var maxLiveTabsDefault: Int = DEFAULT_MAX_LIVE_TABS
+
var maxLiveTabs: Int
- get() = prefs.getInt(MAX_LIVE_TABS_KEY, DEFAULT_MAX_LIVE_TABS).coerceIn(2, 20)
+ get() = prefs.getInt(MAX_LIVE_TABS_KEY, maxLiveTabsDefault).coerceIn(2, 20)
set(value) {
prefs.edit().putInt(MAX_LIVE_TABS_KEY, value.coerceIn(2, 20)).apply()
}
+ /**
+ * Device-aware defaults, called from `BrowserApp.onCreate` before the first tab is restored.
+ * Only lowers limits on small-memory hardware; it never raises above the shipped defaults.
+ */
+ fun applyMemoryAwareDefaults(context: android.content.Context) {
+ try {
+ val am = context.getSystemService(android.content.Context.ACTIVITY_SERVICE)
+ as? android.app.ActivityManager ?: return
+ val info = android.app.ActivityManager.MemoryInfo()
+ am.getMemoryInfo(info)
+ val totalMb = if (info.totalMem > 0) info.totalMem / (1024L * 1024L) else 0L
+ maxLiveTabsDefault = when {
+ // isLowRamDevice() is a method on ActivityManager (API 19), not on MemoryInfo.
+ am.isLowRamDevice || totalMb in 1..1536 -> 3
+ totalMb in 1537..2816 -> 4
+ totalMb > 2816 -> DEFAULT_MAX_LIVE_TABS
+ else -> DEFAULT_MAX_LIVE_TABS
+ }
+ } catch (e: Throwable) {
+ // Keep the shipped default when the memory query is unavailable.
+ }
+ }
+
var adBlockEnabled: Boolean = prefs.getBoolean(ADBLOCK_ENABLED_PREF_KEY, true)
set(value) {
field = value
diff --git a/app/common/src/main/java/com/gothwad/browser/model/WebTabState.kt b/app/common/src/main/java/com/gothwad/browser/model/WebTabState.kt
index 0530546e..2eb59623 100644
--- a/app/common/src/main/java/com/gothwad/browser/model/WebTabState.kt
+++ b/app/common/src/main/java/com/gothwad/browser/model/WebTabState.kt
@@ -41,7 +41,14 @@ data class WebTabState(@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "wv_state_file")
var wvStateFileName: String? = null,
var adblock: Boolean? = null,
- var scale: Float? = null) {
+ var scale: Float? = null,
+ /**
+ * Per-tab "Desktop site" override. `null` means "follow the global default"
+ * (Config.desktopMode), which keeps existing installs behaving as before until
+ * the user actually toggles the menu entry for one tab.
+ */
+ @ColumnInfo(name = "desktop_mode")
+ var desktopMode: Boolean? = null) {
companion object {
val TAG: String = WebTabState::class.java.simpleName
@@ -64,6 +71,9 @@ data class WebTabState(@PrimaryKey(autoGenerate = true)
var lastLoadingUrl: String? = null //this is last url appeared in WebViewClient.shouldOverrideUrlLoading callback
@Ignore
var lastUrlBeforeHome: String? = null
+ /** Title of the page currently covered by the native home screen (in-memory only). */
+ @Ignore
+ var lastTitleBeforeHome: String? = null
@Ignore
var blockedAds = 0
@Ignore
diff --git a/app/common/src/main/java/com/gothwad/browser/utils/HardwareInputManager.kt b/app/common/src/main/java/com/gothwad/browser/utils/HardwareInputManager.kt
index 1966505e..bb2f63d9 100644
--- a/app/common/src/main/java/com/gothwad/browser/utils/HardwareInputManager.kt
+++ b/app/common/src/main/java/com/gothwad/browser/utils/HardwareInputManager.kt
@@ -13,13 +13,19 @@ import android.view.View
import com.gothwad.browser.AppContext
/**
- * HardwareInputManager & Android TV Box Input Management Engine.
+ * Inventory of the physical input devices attached to this box, plus the user's per-device block list.
*
- * Specifically handles:
- * 1. Real-time detection & individual blocking/allowing of connected physical/USB/Bluetooth Keyboards & Mice.
- * 2. Precision mouse click compatibility fix for Android TV Set-Top Boxes (Airtel Xstream, Amlogic, Rockchip)
- * where mouse button presses drop click events on web elements (Google products 9-dot menu, sign-in buttons, SPA buttons).
- * 3. Input filtering and synthetic touch injection pipeline.
+ * Deliberately narrow: it answers "what is plugged in" ([getConnectedDevices], [isPhysicalMouseConnected],
+ * [isPhysicalKeyboardConnected]) and "has the user ignored this device" ([isDeviceBlocked], [setDeviceBlocked]),
+ * and reports changes through [InputDevicesListener]. Everything that *acts* on those answers — the overlay
+ * cursor, joystick-axis D-pad translation, which [com.gothwad.browser.Config.InputMode] the app is in — lives in
+ * the activity/overlay code and is driven by the platform's own flags.
+ *
+ * This class must never rewrite, inject or synthesize an input event: a real event reaches the WebView exactly as
+ * the OS reported it (AGENTS.md §3, rules 2 and 3). The "precision mouse click" synthetic mouse→touch pipeline that
+ * used to be advertised here is gone for good — it broke Chromium's native drag-to-select and only ever papered
+ * over one box's input driver. If clicks are dropped on a device, that is a platform/OEM bug, not something to
+ * compensate for in app code.
*/
class HardwareInputManager private constructor(private val context: Context) : InputManager.InputDeviceListener {
diff --git a/app/common/src/main/java/com/gothwad/browser/webengine/WebEngine.kt b/app/common/src/main/java/com/gothwad/browser/webengine/WebEngine.kt
index 02ce1735..1ac3b1a3 100644
--- a/app/common/src/main/java/com/gothwad/browser/webengine/WebEngine.kt
+++ b/app/common/src/main/java/com/gothwad/browser/webengine/WebEngine.kt
@@ -11,6 +11,12 @@ interface WebEngine {
val url: String?
var userAgentString: String?
+ /**
+ * Resolved per-tab desktop-mode state (see `activity.main.DesktopMode`). Engines that cannot
+ * change their UA at runtime keep the default no-op and simply reload the page instead.
+ */
+ fun setDesktopModeOverride(enabled: Boolean?) {}
+
fun getWebEngineName(): String
fun saveState(): Any?//Bundle or any Object convertible to string
fun restoreState(savedInstanceState: Any)
diff --git a/app/common/src/test/java/com/gothwad/browser/ConfigRulesTest.kt b/app/common/src/test/java/com/gothwad/browser/ConfigRulesTest.kt
new file mode 100644
index 00000000..7087393e
--- /dev/null
+++ b/app/common/src/test/java/com/gothwad/browser/ConfigRulesTest.kt
@@ -0,0 +1,212 @@
+package com.gothwad.browser
+
+import android.content.SharedPreferences
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Pure-JVM tests for the rules in [Config] that the app's behaviour depends on.
+ *
+ * These are the decisions that were silently wrong more than once during development (the live-tab
+ * cap ignoring its own bounds, the desktop/mobile zoom buckets overwriting each other, an unknown
+ * stored input mode crashing instead of falling back), so they are pinned down here without needing
+ * Robolectric or a device.
+ */
+class ConfigRulesTest {
+
+ private class FakePrefs : SharedPreferences {
+ val map = mutableMapOf()
+
+ override fun getAll(): MutableMap = map
+
+ override fun getString(key: String, defValue: String?): String? =
+ if (map.containsKey(key)) map[key] as String? else defValue
+
+ @Suppress("UNCHECKED_CAST")
+ override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? =
+ if (map.containsKey(key)) map[key] as MutableSet else defValues
+
+ override fun getInt(key: String, defValue: Int): Int =
+ if (map.containsKey(key)) (map[key] as Number).toInt() else defValue
+
+ override fun getLong(key: String, defValue: Long): Long =
+ if (map.containsKey(key)) (map[key] as Number).toLong() else defValue
+
+ override fun getFloat(key: String, defValue: Float): Float =
+ if (map.containsKey(key)) (map[key] as Number).toFloat() else defValue
+
+ override fun getBoolean(key: String, defValue: Boolean): Boolean =
+ if (map.containsKey(key)) map[key] as Boolean else defValue
+
+ override fun contains(key: String): Boolean = map.containsKey(key)
+
+ override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor {
+ override fun putString(key: String, value: String?): SharedPreferences.Editor =
+ apply { map[key] = value }
+
+ override fun putStringSet(
+ key: String,
+ values: MutableSet?
+ ): SharedPreferences.Editor = apply { map[key] = values }
+
+ override fun putInt(key: String, value: Int): SharedPreferences.Editor =
+ apply { map[key] = value }
+
+ override fun putLong(key: String, value: Long): SharedPreferences.Editor =
+ apply { map[key] = value }
+
+ override fun putFloat(key: String, value: Float): SharedPreferences.Editor =
+ apply { map[key] = value }
+
+ override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor =
+ apply { map[key] = value }
+
+ override fun remove(key: String): SharedPreferences.Editor = apply { map.remove(key) }
+
+ override fun clear(): SharedPreferences.Editor = apply { map.clear() }
+
+ override fun commit(): Boolean = true
+
+ override fun apply() {}
+ }
+
+ override fun registerOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener?
+ ) {
+ }
+
+ override fun unregisterOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener?
+ ) {
+ }
+ }
+
+ private fun configWith(vararg seed: Pair): Config {
+ val prefs = FakePrefs()
+ seed.forEach { (k, v) -> prefs.map[k] = v }
+ return Config(prefs)
+ }
+
+ // ------------------------------------------------------------------ live tab cap
+
+ @Test
+ fun maxLiveTabs_usesTheAdaptiveDefault_whenTheUserNeverSetOne() {
+ val config = configWith()
+ assertEquals(Config.DEFAULT_MAX_LIVE_TABS, config.maxLiveTabs)
+ config.maxLiveTabsDefault = 3
+ assertEquals(3, config.maxLiveTabs)
+ }
+
+ @Test
+ fun maxLiveTabs_isClampedToTheSaneRange_andRemembered() {
+ val config = configWith()
+ config.maxLiveTabs = 1
+ assertEquals(2, config.maxLiveTabs)
+ config.maxLiveTabs = 99
+ assertEquals(20, config.maxLiveTabs)
+ config.maxLiveTabs = 5
+ assertEquals(5, config.maxLiveTabs)
+ // An explicit value must win over the adaptive default.
+ config.maxLiveTabsDefault = 3
+ assertEquals(5, config.maxLiveTabs)
+ }
+
+ @Test
+ fun maxLiveTabs_reparsesPersistedValueInAFreshInstance() {
+ val prefs = FakePrefs()
+ Config(prefs).maxLiveTabs = 4
+ assertEquals(4, Config(prefs).maxLiveTabs)
+ }
+
+ // ------------------------------------------------------------------ input mode
+
+ @Test
+ fun inputMode_fallsBackToAuto_onAnUnreadableStoredValue() {
+ val config = configWith(Config.INPUT_MODE_KEY to "TOUCHSCREEN_OF_THE_FUTURE")
+ assertEquals(Config.InputMode.AUTO, config.inputMode)
+ }
+
+ @Test
+ fun inputMode_autoResolvesPerDevice() {
+ val config = configWith()
+ assertEquals(Config.InputMode.AUTO, config.inputMode)
+ assertEquals(Config.InputMode.TOUCH, config.resolveInputMode(isTvDevice = false, physicalMouseConnected = false))
+ assertEquals(Config.InputMode.TV_REMOTE, config.resolveInputMode(isTvDevice = true, physicalMouseConnected = false))
+ assertEquals(Config.InputMode.KEYBOARD_MOUSE, config.resolveInputMode(isTvDevice = true, physicalMouseConnected = true))
+ // A phone with a mouse stays a touch device: AUTO only upgrades TV hardware.
+ assertEquals(Config.InputMode.TOUCH, config.resolveInputMode(isTvDevice = false, physicalMouseConnected = true))
+ }
+
+ @Test
+ fun inputMode_explicitChoiceIsNeverOverriddenByTheDevice() {
+ val config = configWith()
+ config.inputMode = Config.InputMode.TOUCH
+ assertEquals(Config.InputMode.TOUCH, config.resolveInputMode(isTvDevice = true, physicalMouseConnected = true))
+ config.inputMode = Config.InputMode.TV_REMOTE
+ assertEquals(Config.InputMode.TV_REMOTE, config.resolveInputMode(isTvDevice = false, physicalMouseConnected = false))
+ }
+
+ // ------------------------------------------------------------------ per-mode zoom
+
+ @Test
+ fun zoom_keepsMobileAndDesktopBucketsSeparate() {
+ val config = configWith()
+ assertEquals(Config.WEB_PAGE_ZOOM_PERCENT_DEFAULT, config.getEffectiveZoom(false))
+ assertEquals(Config.WEB_PAGE_ZOOM_PERCENT_DEFAULT, config.getEffectiveZoom(true))
+
+ config.setEffectiveZoom(true, 150)
+ assertEquals(150, config.getEffectiveZoom(true))
+ assertEquals(Config.WEB_PAGE_ZOOM_PERCENT_DEFAULT, config.getEffectiveZoom(false))
+ }
+
+ @Test
+ fun zoom_clampsToTheSupportedRange() {
+ val config = configWith()
+ config.setEffectiveZoom(false, 10)
+ assertEquals(Config.WEB_PAGE_ZOOM_PERCENT_MIN, config.getEffectiveZoom(false))
+ config.setEffectiveZoom(false, 5000)
+ assertEquals(Config.WEB_PAGE_ZOOM_PERCENT_MAX, config.getEffectiveZoom(false))
+ }
+
+ // ------------------------------------------------------------------ desktop identity
+
+ @Test
+ fun desktopUa_isRecognisedFromTheUaString() {
+ val config = configWith()
+ assertTrue(config.isDesktopUa(Config.DESKTOP_UA))
+ assertFalse(config.isDesktopUa("Mozilla/5.0 (Linux; Android 13; SM-A135F) Mobile Safari/537.36"))
+ assertFalse(config.isDesktopUa(null))
+ }
+
+ @Test
+ fun desktopMode_globalFlagOrCustomUaBothTurnItOn() {
+ val config = configWith()
+ assertFalse(config.isDesktopMode())
+ config.desktopMode.value = true
+ assertTrue(config.isDesktopMode())
+
+ val custom = configWith(Config.USER_AGENT_PREF_KEY to "Firefox/128.0 on X11; Linux x86_64")
+ assertFalse(custom.desktopMode.value)
+ assertTrue(custom.isDesktopMode())
+ }
+
+ @Test
+ fun privacyAndSafetyFlags_defaultOnAndPersistWhenDisabled() {
+ // The shipped app once advertised "securest browser" while Safe Browsing was pinned to
+ // false; these defaults are the product promise, so they are asserted, not assumed.
+ val config = configWith()
+ assertTrue("safe browsing must default to on", config.safeBrowsingEnabled)
+ assertTrue("history must default to on", config.saveHistory)
+ assertFalse("autoplay must default to off", config.allowAutoplayMedia)
+
+ config.safeBrowsingEnabled = false
+ config.saveHistory = false
+ assertFalse(config.safeBrowsingEnabled)
+ assertFalse(config.saveHistory)
+
+ val persisted = FakePrefs().apply { map[Config.SAFE_BROWSING_KEY] = false }
+ assertFalse(Config(persisted).safeBrowsingEnabled)
+ }
+}
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" />
0 && !this.paused && !this.ended && this.readyState > 2);
- },
- configurable: true,
- enumerable: false
- });
- } catch (e) {
- // Ignore if already defined by host page or previous injection
+// media playback control support (driven by the media / DPAD keys handled in MainActivity)
+// NOTE: deliberately NOT a HTMLMediaElement.prototype patch - injecting a getter into a platform
+// prototype changes every page script on the site and can be detected / re-defined by the host page.
+function tvBroMediaIsPlaying(m) {
+ return !!(m && m.currentTime > 0 && !m.paused && !m.ended && m.readyState > 2);
+}
+
+// The element the media keys should drive: whatever is actually playing, else the first one.
+function tvBroActiveMedia() {
+ var list = document.querySelectorAll('video, audio');
+ for (var i = 0; i < list.length; i++) {
+ if (tvBroMediaIsPlaying(list[i])) {
+ return list[i];
+ }
}
+ return list.length > 0 ? list[0] : null;
}
window.tvBroTogglePlayback = function() {
- var media = document.querySelector('video') || document.querySelector('audio');
- if (media) {
- if (media.playing) {
- media.pause();
- } else {
- media.play();
+ var media = tvBroActiveMedia();
+ if (!media) {
+ return;
+ }
+ if (tvBroMediaIsPlaying(media)) {
+ media.pause();
+ } else {
+ var p = media.play();
+ // Autoplay policies reject the promise; swallow it instead of logging an uncaught error.
+ if (p && typeof p.catch === 'function') {
+ p.catch(function() {});
}
}
}
window.tvBroStopPlayback = function() {
- var media = document.querySelector('video') || document.querySelector('audio');
+ var media = tvBroActiveMedia();
if (media) {
media.pause();
media.currentTime = 0;
@@ -61,14 +69,14 @@ window.tvBroStopPlayback = function() {
}
window.tvBroRewind = function() {
- var media = document.querySelector('video') || document.querySelector('audio');
+ var media = tvBroActiveMedia();
if (media) {
media.currentTime -= 10;
}
}
window.tvBroFastForward = function() {
- var media = document.querySelector('video') || document.querySelector('audio');
+ var media = tvBroActiveMedia();
if (media) {
media.currentTime += 10;
}
@@ -83,132 +91,6 @@ window.addEventListener("touchstart", function(e) {
}
});
-// Android TV / STB Physical Mouse & Remote Precision Click Compatibility Engine
-(function() {
- if (window.__tvHardwareMouseFixApplied) return;
- window.__tvHardwareMouseFixApplied = true;
-
- function findInteractiveAncestor(el) {
- if (!el || el === document.body || el === document.documentElement) return null;
- var cur = el;
- while (cur && cur !== document.body && cur !== document.documentElement) {
- var tag = cur.tagName ? cur.tagName.toUpperCase() : "";
- if (tag === 'A' || tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' || tag === 'LABEL') {
- return cur;
- }
- if (cur.getAttribute) {
- var role = cur.getAttribute('role');
- if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab' || role === 'checkbox' || role === 'switch') {
- return cur;
- }
- if (cur.hasAttribute('onclick') || cur.hasAttribute('jsaction') || cur.hasAttribute('data-action') || cur.hasAttribute('aria-haspopup')) {
- return cur;
- }
- var ariaLabel = (cur.getAttribute('aria-label') || "").toLowerCase();
- if (ariaLabel.indexOf('google apps') !== -1 || ariaLabel.indexOf('sign in') !== -1 || ariaLabel.indexOf('sign up') !== -1 || ariaLabel.indexOf('menu') !== -1) {
- return cur;
- }
- if (cur.classList && (cur.classList.contains('gb_d') || cur.classList.contains('gb_wa') || cur.classList.contains('gb_A') || cur.classList.contains('gb_B'))) {
- return cur;
- }
- }
- cur = cur.parentElement;
- }
- return null;
- }
-
- var lastPointerDownInfo = null;
-
- document.addEventListener("pointerdown", function(e) {
- lastPointerDownInfo = {
- x: e.clientX,
- y: e.clientY,
- time: Date.now(),
- target: e.target
- };
- }, true);
-
- document.addEventListener("touchstart", function(e) {
- if (e.touches && e.touches.length > 0) {
- lastPointerDownInfo = {
- x: e.touches[0].clientX,
- y: e.touches[0].clientY,
- time: Date.now(),
- target: e.target
- };
- }
- }, true);
-
- var clickTriggeredRecently = false;
- document.addEventListener("click", function(e) {
- clickTriggeredRecently = true;
- setTimeout(function() { clickTriggeredRecently = false; }, 200);
-
- var interactive = findInteractiveAncestor(e.target);
- if (interactive) {
- if (interactive.tagName === 'INPUT' || interactive.tagName === 'TEXTAREA' || interactive.isContentEditable) {
- if (typeof interactive.focus === 'function') {
- interactive.focus();
- }
- }
- }
- }, true);
-
- function handlePointerUpOrEnd(clientX, clientY, target) {
- if (!lastPointerDownInfo) return;
- var dx = Math.abs(clientX - lastPointerDownInfo.x);
- var dy = Math.abs(clientY - lastPointerDownInfo.y);
- var dt = Date.now() - lastPointerDownInfo.time;
-
- // If it was a clean stationary click (<= 15px movement within 800ms)
- if (dx <= 18 && dy <= 18 && dt < 800) {
- var hit = document.elementFromPoint(clientX, clientY) || target;
- var interactive = findInteractiveAncestor(hit);
-
- if (interactive) {
- // Focus interactive inputs
- if (interactive.tagName === 'INPUT' || interactive.tagName === 'TEXTAREA' || interactive.isContentEditable) {
- if (typeof interactive.focus === 'function') {
- interactive.focus();
- }
- }
-
- // If native click did not dispatch (e.g. dropped by TV box driver on SVG child)
- setTimeout(function() {
- if (!clickTriggeredRecently) {
- try {
- if (typeof interactive.click === 'function') {
- interactive.click();
- } else {
- var evt = new MouseEvent('click', {
- bubbles: true,
- cancelable: true,
- view: window,
- clientX: clientX,
- clientY: clientY
- });
- interactive.dispatchEvent(evt);
- }
- } catch (err) {
- // ignore
- }
- }
- }, 40);
- }
- }
- }
-
- document.addEventListener("pointerup", function(e) {
- handlePointerUpOrEnd(e.clientX, e.clientY, e.target);
- }, true);
-
- document.addEventListener("touchend", function(e) {
- if (lastPointerDownInfo) {
- handlePointerUpOrEnd(lastPointerDownInfo.x, lastPointerDownInfo.y, e.target);
- }
- }, true);
-})();
-
// Real Desktop Mode fitting for TV Browser
(function() {
var ua = navigator.userAgent || "";
diff --git a/app/src/main/java/com/gothwad/browser/BrowserApp.kt b/app/src/main/java/com/gothwad/browser/BrowserApp.kt
index 6ee42cd3..af42c68f 100644
--- a/app/src/main/java/com/gothwad/browser/BrowserApp.kt
+++ b/app/src/main/java/com/gothwad/browser/BrowserApp.kt
@@ -64,6 +64,8 @@ class BrowserApp : Application(), Application.ActivityLifecycleCallbacks {
instance = this
AppContext.init(this, Config(getSharedPreferences(MAIN_PREFS_NAME, MODE_MULTI_PROCESS)))
+ // Must run before the first tab is restored: it decides how many WebViews may stay live.
+ AppContext.provideConfig().applyMemoryAwareDefaults(this)
val maxThreadsInOfflineJobsPool = Runtime.getRuntime().availableProcessors()
threadPool = ThreadPoolExecutor(0, maxThreadsInOfflineJobsPool, 20,
diff --git a/app/src/main/java/com/gothwad/browser/activity/history/HistoryActivity.kt b/app/src/main/java/com/gothwad/browser/activity/history/HistoryActivity.kt
index 54741fbe..fff3bcc8 100644
--- a/app/src/main/java/com/gothwad/browser/activity/history/HistoryActivity.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/history/HistoryActivity.kt
@@ -123,8 +123,8 @@ class HistoryActivity : AppCompatActivity(), AdapterView.OnItemClickListener, Ad
}
private fun updateFilterUI(filter: Int) {
- val activeColor = 0xFF38BDF8.toInt()
- val inactiveColor = 0xFF94A3B8.toInt()
+ val activeColor = androidx.core.content.ContextCompat.getColor(this, R.color.app_accent)
+ val inactiveColor = androidx.core.content.ContextCompat.getColor(this, R.color.app_on_surface_muted)
vb.btnFilterAll.setTextColor(if (filter == HistoryModel.FILTER_ALL) activeColor else inactiveColor)
vb.btnFilterToday.setTextColor(if (filter == HistoryModel.FILTER_TODAY) activeColor else inactiveColor)
@@ -215,7 +215,7 @@ class HistoryActivity : AppCompatActivity(), AdapterView.OnItemClickListener, Ad
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("URL", item.url)
clipboard.setPrimaryClip(clip)
- Toast.makeText(this, "Link copied to clipboard", Toast.LENGTH_SHORT).show()
+ Toast.makeText(this, this.getString(R.string.history_link_copied_to_clipboard), Toast.LENGTH_SHORT).show()
true
}
4 -> {
@@ -282,8 +282,8 @@ class HistoryActivity : AppCompatActivity(), AdapterView.OnItemClickListener, Ad
private fun showClearBrowsingDataDialog() {
AlertDialog.Builder(this)
- .setTitle("Clear Browsing Data")
- .setMessage("Delete all browsing history, cached files, and cookies?")
+ .setTitle(R.string.history_clear_browsing_data)
+ .setMessage(R.string.history_delete_all_confirm)
.setPositiveButton("Clear Everything") { _, _ ->
lifecycleScope.launch(Dispatchers.Main) {
try {
diff --git a/app/src/main/java/com/gothwad/browser/activity/lock/AppLockActivity.kt b/app/src/main/java/com/gothwad/browser/activity/lock/AppLockActivity.kt
index ccd27945..601cf037 100644
--- a/app/src/main/java/com/gothwad/browser/activity/lock/AppLockActivity.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/lock/AppLockActivity.kt
@@ -127,13 +127,29 @@ class AppLockActivity : AppCompatActivity() {
private fun checkPin() {
val entered = inputPin.toString()
- if (AppLockManager.verifyPin(this, entered)) {
+ if (AppLockManager.isLockedOut(this)) {
+ Toast.makeText(
+ this,
+ getString(R.string.app_lock_locked_out, (AppLockManager.lockoutRemainingMs(this) / 1000L).toInt()),
+ Toast.LENGTH_LONG
+ ).show()
+ inputPin.clear()
+ updateDots()
+ return
+ }
+ val result = AppLockManager.verifyPinDetailed(this, entered)
+ if (result.success) {
AppLockManager.setSessionUnlocked(this, true)
setResult(RESULT_OK)
finish()
overridePendingTransition(0, 0)
} else {
- Toast.makeText(this, "Incorrect PIN", Toast.LENGTH_SHORT).show()
+ val message = if (result.lockoutMs > 0) {
+ getString(R.string.app_lock_locked_out, (result.lockoutMs / 1000L).toInt())
+ } else {
+ getString(R.string.app_lock_pin_incorrect)
+ }
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
dotsContainer?.animate()
?.translationXBy(20f)
?.setDuration(60)
diff --git a/app/src/main/java/com/gothwad/browser/activity/lock/TvPinDialog.kt b/app/src/main/java/com/gothwad/browser/activity/lock/TvPinDialog.kt
index 314c1878..b85f0f33 100644
--- a/app/src/main/java/com/gothwad/browser/activity/lock/TvPinDialog.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/lock/TvPinDialog.kt
@@ -71,36 +71,36 @@ class TvPinDialog(
when (mode) {
Mode.CREATE -> {
if (step == 1) {
- tvTitle.text = "🔒 Set 4-Digit TV PIN"
- tvSubtitle.text = "Step 1/2: Enter new 4-digit PIN"
- tvSubtitle.setTextColor(0xFF94A3B8.toInt())
+ tvTitle.setText(R.string.app_lock_pin_title_set)
+ tvSubtitle.text = context.getString(R.string.app_lock_step_new, 2)
+ tvSubtitle.setTextColor(themedColor(R.color.google_text_secondary))
} else {
- tvTitle.text = "🔒 Confirm 4-Digit PIN"
- tvSubtitle.text = "Step 2/2: Re-enter PIN to confirm"
- tvSubtitle.setTextColor(0xFF38BDF8.toInt())
+ tvTitle.setText(R.string.app_lock_pin_title_confirm)
+ tvSubtitle.text = context.getString(R.string.app_lock_step_confirm, 2)
+ tvSubtitle.setTextColor(themedColor(R.color.google_accent))
}
}
Mode.VERIFY -> {
- tvTitle.text = "🔒 Enter 4-Digit PIN"
- tvSubtitle.text = "Enter current PIN to continue"
- tvSubtitle.setTextColor(0xFF94A3B8.toInt())
+ tvTitle.setText(R.string.app_lock_pin_title_enter)
+ tvSubtitle.setText(R.string.app_lock_pin_title_verify_current)
+ tvSubtitle.setTextColor(themedColor(R.color.google_text_secondary))
}
Mode.CHANGE -> {
when (step) {
1 -> {
- tvTitle.text = "🔒 Verify Current PIN"
- tvSubtitle.text = "Step 1/3: Enter current PIN"
- tvSubtitle.setTextColor(0xFF94A3B8.toInt())
+ tvTitle.setText(R.string.app_lock_pin_title_verify_current)
+ tvSubtitle.text = context.getString(R.string.app_lock_step_verify, 3)
+ tvSubtitle.setTextColor(themedColor(R.color.google_text_secondary))
}
2 -> {
- tvTitle.text = "🔒 Enter New PIN"
- tvSubtitle.text = "Step 2/3: Enter new 4-digit PIN"
- tvSubtitle.setTextColor(0xFF38BDF8.toInt())
+ tvTitle.setText(R.string.app_lock_pin_title_new)
+ tvSubtitle.text = context.getString(R.string.app_lock_step_new_3, 3)
+ tvSubtitle.setTextColor(themedColor(R.color.google_accent))
}
3 -> {
- tvTitle.text = "🔒 Confirm New PIN"
- tvSubtitle.text = "Step 3/3: Re-enter new PIN"
- tvSubtitle.setTextColor(0xFF38BDF8.toInt())
+ tvTitle.setText(R.string.app_lock_pin_title_confirm_new)
+ tvSubtitle.text = context.getString(R.string.app_lock_step_confirm_3, 3)
+ tvSubtitle.setTextColor(themedColor(R.color.google_accent))
}
}
}
@@ -171,11 +171,11 @@ class TvPinDialog(
if (entered == tempNewPin) {
AppLockManager.setPin(context, entered)
AppLockManager.setLockEnabled(context, true)
- Toast.makeText(context, "✅ 4-Digit PIN Set Successfully!", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, R.string.app_lock_pin_saved, Toast.LENGTH_SHORT).show()
dismiss()
onSuccess?.invoke(entered)
} else {
- showError("PINs do not match! Try again.")
+ showError(context.getString(R.string.app_lock_pin_mismatch))
step = 1
tempNewPin = null
setupStepUI()
@@ -183,21 +183,29 @@ class TvPinDialog(
}
}
Mode.VERIFY -> {
- if (AppLockManager.verifyPin(context, entered)) {
+ val result = AppLockManager.verifyPinDetailed(context, entered)
+ if (result.success) {
dismiss()
onSuccess?.invoke(entered)
} else {
- showError("Incorrect PIN! Try again.")
+ showError(
+ if (result.lockoutMs > 0) {
+ context.getString(R.string.app_lock_locked_out, (result.lockoutMs / 1000L).toInt())
+ } else {
+ context.getString(R.string.app_lock_pin_incorrect)
+ }
+ )
}
}
Mode.CHANGE -> {
when (step) {
1 -> {
- if (AppLockManager.verifyPin(context, entered)) {
+ val current = AppLockManager.verifyPinDetailed(context, entered)
+ if (current.success) {
step = 2
setupStepUI()
} else {
- showError("Incorrect Current PIN!")
+ showError(context.getString(R.string.app_lock_pin_incorrect_current))
}
}
2 -> {
@@ -208,11 +216,11 @@ class TvPinDialog(
3 -> {
if (entered == tempNewPin) {
AppLockManager.setPin(context, entered)
- Toast.makeText(context, "✅ PIN Changed Successfully!", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, R.string.app_lock_pin_changed, Toast.LENGTH_SHORT).show()
dismiss()
onSuccess?.invoke(entered)
} else {
- showError("New PINs do not match! Try again.")
+ showError(context.getString(R.string.app_lock_pin_mismatch))
step = 2
tempNewPin = null
setupStepUI()
@@ -225,7 +233,7 @@ class TvPinDialog(
private fun showError(msg: String) {
tvSubtitle.text = msg
- tvSubtitle.setTextColor(0xFFEF4444.toInt())
+ tvSubtitle.setTextColor(themedColor(R.color.google_error))
dotsContainer.animate()
?.translationXBy(20f)
@@ -246,6 +254,9 @@ class TvPinDialog(
updateDots()
}
+ private fun themedColor(colorRes: Int): Int =
+ androidx.core.content.ContextCompat.getColor(context, colorRes)
+
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_0, KeyEvent.KEYCODE_NUMPAD_0 -> { appendDigit("0"); return true }
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/DesktopMode.kt b/app/src/main/java/com/gothwad/browser/activity/main/DesktopMode.kt
new file mode 100644
index 00000000..36da26d5
--- /dev/null
+++ b/app/src/main/java/com/gothwad/browser/activity/main/DesktopMode.kt
@@ -0,0 +1,83 @@
+package com.gothwad.browser.activity.main
+
+import com.gothwad.browser.R
+import com.gothwad.browser.model.WebTabState
+
+/**
+ * Desktop-site mode.
+ *
+ * Historically this app had exactly one desktop switch (`Config.desktopMode`) that was applied to
+ * *every* live tab at once, so turning "Desktop site" on in one tab also reloaded whatever the user
+ * was reading in another tab. Chrome's model - and therefore the model used here - is:
+ *
+ * - `Config.desktopMode` is only the **default for newly created tabs** (it is the switch in
+ * Settings, plus the value restored from a persisted tab that has no override yet);
+ * - `WebTabState.desktopMode` is the per-tab override (null = follow the default);
+ * - the WebView itself keeps the resolved value in [com.gothwad.browser.webengine.webview.WebViewEx.desktopModeOverride]
+ * so page-zoom and the UA stay correct while the user switches tabs.
+ *
+ * All menu / sidebar entries must go through [toggleDesktopModeForCurrentTab] instead of poking
+ * `config.desktopMode` directly, otherwise the three entry points drift apart again.
+ */
+
+/** Effective desktop-mode state of a tab (per-tab override first, then the global default). */
+fun MainActivity.effectiveDesktopMode(tab: WebTabState?): Boolean {
+ val override = tab?.desktopMode
+ if (override != null) {
+ return override
+ }
+ val customUa = tab?.webEngine?.userAgentString ?: config.userAgentString.value
+ return config.isDesktopMode(customUa)
+}
+
+/**
+ * Set (or clear, with `desktop = null`) the desktop-mode override of one tab.
+ *
+ * Only the affected tab is re-zoomed and reloaded; other tabs keep their own state.
+ */
+fun MainActivity.setTabDesktopMode(tab: WebTabState?, desktop: Boolean?, reload: Boolean = true) {
+ tab ?: return
+ if (tab.desktopMode == desktop) {
+ return
+ }
+ tab.desktopMode = desktop
+ val isDesktop = effectiveDesktopMode(tab)
+ tab.webEngine.setDesktopModeOverride(desktop ?: config.desktopMode.value)
+ tab.webEngine.setPageZoom(config.getEffectiveZoom(isDesktop))
+ if (reload && tab === tabsModel.currentTab.value) {
+ tab.webEngine.reload()
+ }
+ tabsModel.persistDesktopMode(tab)
+}
+
+/**
+ * Toggle desktop mode for the current tab.
+ *
+ * @param force explicit target state, `null` = flip whatever the tab is showing now.
+ * @return the new state, so callers can show a matching toast / checkbox.
+ */
+fun MainActivity.toggleDesktopModeForCurrentTab(force: Boolean? = null, reload: Boolean = true): Boolean {
+ val tab = tabsModel.currentTab.value
+ val next = force ?: !effectiveDesktopMode(tab)
+ setTabDesktopMode(tab, next, reload)
+ return next
+}
+
+/**
+ * The global default changed (Settings -> "Desktop mode"). Push it to every tab that has no
+ * explicit per-tab override, which is exactly the set of tabs that follow the global value.
+ */
+fun MainActivity.applyDesktopModeDefault() {
+ val isDesktop = config.desktopMode.value
+ val zoom = config.getEffectiveZoom(isDesktop)
+ for (tab in tabsModel.tabsStates) {
+ if (tab.desktopMode == null) {
+ tab.webEngine.setDesktopModeOverride(isDesktop)
+ tab.webEngine.setPageZoom(zoom)
+ }
+ }
+}
+
+/** Text used by the menu / sidebar entries and their toasts. */
+fun MainActivity.desktopModeToastText(desktop: Boolean): String =
+ getString(if (desktop) R.string.desktop_site_on else R.string.desktop_site_off)
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/InputMode.kt b/app/src/main/java/com/gothwad/browser/activity/main/InputMode.kt
new file mode 100644
index 00000000..48670f61
--- /dev/null
+++ b/app/src/main/java/com/gothwad/browser/activity/main/InputMode.kt
@@ -0,0 +1,116 @@
+package com.gothwad.browser.activity.main
+
+import android.widget.TextView
+import com.gothwad.browser.Config
+import com.gothwad.browser.R
+import com.gothwad.browser.utils.HardwareInputManager
+import com.gothwad.browser.utils.Utils
+
+/**
+ * Input mode plumbing: the single place where "remote only", "keyboard + mouse" and "touch" are
+ * translated into the concrete switches the rest of the app already respects.
+ *
+ * Nothing here invents a new mechanism - every flag it writes is consumed by existing code:
+ * - [Config.enableVirtualCursor] -> `CursorLayout.dispatchKeyEvent/dispatchGenericMotionEvent`,
+ * `MainActivityDpad`, `CursorDrawerDelegate`
+ * - [Config.disableMotionAxesDpadNavigation] -> `CursorLayout.motionAxesTranslationEnabled`
+ * - [Config.desktopMode] -> the default desktop/mobile identity for tabs (per-tab override lives in
+ * `activity.main.DesktopMode`)
+ *
+ * The point of the mode is that a TV user gets a working pointer + desktop pages with one tap
+ * instead of hunting through four sub-screens, and that turning the virtual cursor off (mouse mode)
+ * cannot leave two pointers on screen.
+ */
+
+/** The mode that is actually in effect right now (resolves [Config.InputMode.AUTO]). */
+fun MainActivity.effectiveInputMode(): Config.InputMode {
+ val mouseConnected = runCatching {
+ HardwareInputManager.getInstance(this).isPhysicalMouseConnected()
+ }.getOrDefault(false)
+ val isTv = runCatching { Utils.isTV(this) }.getOrDefault(false)
+ return config.resolveInputMode(isTv, mouseConnected)
+}
+
+fun MainActivity.inputModeTitle(mode: Config.InputMode): String = getString(
+ when (mode) {
+ Config.InputMode.AUTO -> R.string.input_mode_auto
+ Config.InputMode.TV_REMOTE -> R.string.input_mode_tv_remote
+ Config.InputMode.KEYBOARD_MOUSE -> R.string.input_mode_keyboard_mouse
+ Config.InputMode.TOUCH -> R.string.input_mode_touch
+ }
+)
+
+/**
+ * Write the concrete switches for [mode]. Safe to call on every resume: it only persists values that
+ * actually changed, and all consumers read the flags per event.
+ */
+fun MainActivity.applyInputMode(mode: Config.InputMode = effectiveInputMode()) {
+ when (mode) {
+ Config.InputMode.TV_REMOTE -> {
+ config.enableVirtualCursor = true
+ config.disableMotionAxesDpadNavigation = false
+ }
+
+ Config.InputMode.KEYBOARD_MOUSE -> {
+ // The OS pointer already moves on its own - drawing our cursor on top of it gave TV users
+ // a double cursor that fought with the real one.
+ config.enableVirtualCursor = false
+ config.disableMotionAxesDpadNavigation = false
+ }
+
+ Config.InputMode.TOUCH -> {
+ config.enableVirtualCursor = false
+ config.disableMotionAxesDpadNavigation = true
+ }
+
+ Config.InputMode.AUTO -> {
+ // Never stored as a resolved state; callers resolve first. Keep the device default.
+ applyInputMode(effectiveInputMode())
+ }
+ }
+}
+
+/**
+ * Explicit user choice from Settings. TV modes also switch new tabs to the desktop page identity,
+ * because "the TV shows the full desktop site" is a hard product requirement, not an extra toggle.
+ */
+fun MainActivity.setInputMode(mode: Config.InputMode) {
+ config.inputMode = mode
+ applyInputMode(if (mode == Config.InputMode.AUTO) effectiveInputMode() else mode)
+ if (mode == Config.InputMode.TV_REMOTE || mode == Config.InputMode.KEYBOARD_MOUSE) {
+ if (!config.desktopMode.value) {
+ config.desktopMode.value = true
+ applyDesktopModeDefault()
+ }
+ }
+}
+
+/** Bind the four segmented buttons at the top of the settings dialog to the current mode. */
+fun MainActivity.bindInputModeSegments(
+ auto: TextView?,
+ tvRemote: TextView?,
+ keyboardMouse: TextView?,
+ touch: TextView?,
+ onAfterChange: () -> Unit = {}
+) {
+ val buttons = mapOf(
+ Config.InputMode.AUTO to auto,
+ Config.InputMode.TV_REMOTE to tvRemote,
+ Config.InputMode.KEYBOARD_MOUSE to keyboardMouse,
+ Config.InputMode.TOUCH to touch
+ )
+ fun paint() {
+ val stored = config.inputMode
+ buttons.forEach { (mode, button) ->
+ button?.isActivated = mode == stored
+ }
+ }
+ buttons.forEach { (mode, button) ->
+ button?.setOnClickListener {
+ setInputMode(mode)
+ paint()
+ onAfterChange()
+ }
+ }
+ paint()
+}
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/MainActivity.kt b/app/src/main/java/com/gothwad/browser/activity/main/MainActivity.kt
index 2684bf36..c5ab59e6 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/MainActivity.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/MainActivity.kt
@@ -3,11 +3,9 @@ package com.gothwad.browser.activity.main
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
-import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
-import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.content.pm.PackageManager
@@ -72,6 +70,8 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback {
internal lateinit var uiHandler: Handler
internal var isFullscreen: Boolean = false
internal lateinit var prefs: SharedPreferences
+ private val connectivityManager: ConnectivityManager?
+ get() = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
internal val config = AppContext.provideConfig()
internal val voiceSearchHelper = VoiceSearchHelper(this, VOICE_SEARCH_REQUEST_CODE, MY_PERMISSIONS_REQUEST_VOICE_SEARCH_PERMISSIONS)
internal var lastCommonRequestsCode = COMMON_REQUESTS_START_CODE
@@ -95,13 +95,19 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback {
vb.progressBarGeneric.visibility = View.GONE
}
- private val mConnectivityChangeReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- val activeNetwork = cm.activeNetworkInfo
- val isConnected = activeNetwork != null && activeNetwork.isConnectedOrConnecting
- val tab = tabsModel.currentTab.value ?: return
- tab.webEngine.setNetworkAvailable(isConnected)
+ /**
+ * `android.net.conn.CONNECTIVITY_CHANGE` is deprecated since API 28 and `activeNetworkInfo`
+ * since API 29; on many TV boxes and on Android 14+ the broadcast is delayed or not delivered
+ * at all, which left the WebView believing it was still offline (offline error pages, no
+ * retry after reconnecting). A NetworkCallback is the supported, push-based replacement and
+ * also costs less than a broadcast round-trip on low-end hardware.
+ */
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
+
+ private val onNetworkStateChanged = {
+ val tab = tabsModel.currentTab.value
+ if (tab != null) {
+ tab.webEngine.setNetworkAvailable(com.gothwad.browser.utils.Utils.isNetworkConnected(this))
}
}
@@ -206,6 +212,7 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback {
onBackPressedDispatcher.addCallback(onBackPressedCallback)
applySoftInputMode()
HardwareMousePointerManager.applyToWindow(window)
+ applyInputMode()
window.decorView.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
if (config.disableVirtualKeyboard && newFocus != null) {
@@ -262,6 +269,7 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback {
}
override fun onDestroy() {
+ unregisterNetworkCallback()
voiceSearchHelper.destroy()
if (::tabsModel.isInitialized) {
tabsModel.onDetachActivity()
@@ -282,7 +290,14 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback {
if (::tabsModel.isInitialized) {
tabsModel.onTrimMemory()
}
- if (level >= 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 +369,43 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback {
super.onResume()
applySoftInputMode()
HardwareMousePointerManager.applyToWindow(window)
- val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE")
- registerReceiver(mConnectivityChangeReceiver, intentFilter)
+ // In AUTO mode this is what reacts to a mouse being plugged in / out while we were away.
+ applyInputMode()
+ 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()
@@ -372,7 +417,7 @@ open class MainActivity : AppCompatActivity(), ActionBar.Callback {
}
fun navigate(url: String) = navigateInternal(url)
- fun navigateBack(goHomeIfNoHistory: Boolean = false) = navigateBackInternal(goHomeIfNoHistory)
+ fun navigateBack() = navigateBackInternal()
fun refresh() = refreshInternal()
fun applyScreenOrientation() = applyScreenOrientationInternal()
suspend fun showPopupBlockOptions() = showPopupBlockOptionsInternal()
@@ -393,7 +438,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/MainActivityEvents.kt b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityEvents.kt
index 9313afc6..98f533b3 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityEvents.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityEvents.kt
@@ -134,16 +134,15 @@ internal fun MainActivity.handleBackNavigation() {
return
}
- val isNativeHomeVisible = vb.vNativeHome.isVisible
val currentTab = tabsModel.currentTab.value
- val currentUrl = currentTab?.url ?: ""
- val isHome = isNativeHomeVisible || currentTab == null ||
- currentUrl.isEmpty() ||
- currentUrl == settingsModel.homePage ||
- currentUrl == Config.HOME_PAGE_URL ||
- currentUrl == Config.HOME_URL_ALIAS
+ val isHome = currentTab == null || isHomeUrl(currentTab.url)
if (isHome) {
+ // Back on the home screen first reveals the page Home was covering; only a home screen
+ // with nothing behind it takes part in double-press-to-exit.
+ if (vb.vNativeHome.isVisible && hideHomeScreen()) {
+ return
+ }
val now = System.currentTimeMillis()
if (now - lastBackPressTime < 2500L) {
closeWindow()
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..d3f701d8 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,28 +108,49 @@ 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
}
+/** True when the native home screen covers the page of the current tab. */
+internal fun MainActivity.isHomeScreenShown(): Boolean = vb.vNativeHome.isVisible
+
+/** Single definition of "this url is the home screen", used by every layout / menu / back path. */
+internal fun MainActivity.isHomeUrl(url: String?): Boolean =
+ url.isNullOrEmpty() ||
+ url == settingsModel.homePage ||
+ url == Config.HOME_PAGE_URL ||
+ url == Config.HOME_URL_ALIAS ||
+ url == "about:blank"
+
+/**
+ * Show the native home screen.
+ *
+ * This is deliberately NOT a navigation: the WebView keeps its document and its whole
+ * back/forward list, the only thing that changes is which view is on top. `loadUrl("about:blank")`
+ * + `clearHistory()` (what this used to do) destroyed the page the user was reading, so a single
+ * accidental Home tap cost a full reload. Playback is paused so nothing keeps making noise behind
+ * the home screen.
+ */
internal fun MainActivity.showHomeScreen() {
val currentTab = tabsModel.currentTab.value
if (currentTab != null) {
val currentUrl = currentTab.url
- val isAlreadyHome = currentUrl.isEmpty() ||
- currentUrl == settingsModel.homePage ||
- currentUrl == Config.HOME_PAGE_URL ||
- currentUrl == Config.HOME_URL_ALIAS
-
- if (!isAlreadyHome) {
+ if (!isHomeUrl(currentUrl)) {
currentTab.lastUrlBeforeHome = currentUrl
+ currentTab.lastTitleBeforeHome = currentTab.title
+ currentTab.webEngine.stopPlayback()
}
currentTab.url = Config.HOME_URL_ALIAS
currentTab.title = getString(R.string.home_screen)
currentTab.thumbnail = null
- currentTab.webEngine.clearHistory()
- currentTab.webEngine.loadUrl("about:blank")
lifecycleScope.launch(Dispatchers.IO) {
tabsModel.saveTab(currentTab)
}
@@ -139,15 +160,42 @@ internal fun MainActivity.showHomeScreen() {
vb.rlActionBar.bringToFront()
vb.vActionBar.setAddressBoxText("")
updateBackForwardButtons(
- canGoBack = false,
- canGoForward = !currentTab?.lastUrlBeforeHome.isNullOrEmpty()
+ canGoBack = currentTab?.lastUrlBeforeHome.isNullOrEmpty() == false,
+ canGoForward = false
)
showMenuOverlay()
- vb.ibHome.requestFocus()
+ requestHeaderFocus()
+}
+
+/**
+ * Reveal the page that [showHomeScreen] covered.
+ *
+ * @return false when there is nothing behind the home screen (fresh tab), so callers can fall back
+ * to their own behaviour (double-press-to-exit on the home screen).
+ */
+internal fun MainActivity.hideHomeScreen(): Boolean {
+ val tab = tabsModel.currentTab.value ?: return false
+ val restoreUrl = tab.lastUrlBeforeHome
+ if (restoreUrl.isNullOrEmpty()) {
+ return false
+ }
+ tab.lastUrlBeforeHome = null
+ tab.url = restoreUrl
+ tab.title = tab.lastTitleBeforeHome ?: restoreUrl
+ tab.lastTitleBeforeHome = null
+ vb.vNativeHome.visibility = View.GONE
+ vb.flWebViewContainer.visibility = View.VISIBLE
+ vb.vActionBar.setAddressBoxText(restoreUrl)
+ updateBackForwardButtons(
+ canGoBack = tab.webEngine.canGoBack(),
+ canGoForward = tab.webEngine.canGoForward()
+ )
+ switchToTab(tab)
+ return true
}
internal fun MainActivity.switchToTab(newTab: WebTabState) {
- val isHome = newTab.url == settingsModel.homePage || newTab.url == Config.HOME_PAGE_URL || newTab.url == Config.HOME_URL_ALIAS || newTab.url.isEmpty() || newTab.url == "about:blank"
+ val isHome = isHomeUrl(newTab.url)
changeTab(newTab)
if (isHome) {
vb.vNativeHome.visibility = View.VISIBLE
@@ -155,11 +203,11 @@ internal fun MainActivity.switchToTab(newTab: WebTabState) {
vb.rlActionBar.bringToFront()
vb.vActionBar.setAddressBoxText("")
updateBackForwardButtons(
- canGoBack = false,
- canGoForward = !newTab.lastUrlBeforeHome.isNullOrEmpty()
+ canGoBack = !newTab.lastUrlBeforeHome.isNullOrEmpty(),
+ canGoForward = false
)
showMenuOverlay()
- vb.ibHome.requestFocus()
+ requestHeaderFocus()
} else {
vb.vNativeHome.visibility = View.GONE
vb.flWebViewContainer.visibility = View.VISIBLE
@@ -251,13 +299,15 @@ internal fun MainActivity.createWebView(tab: WebTabState): View? {
}
var ua = config.userAgentString.value
- if (ua == null && config.desktopMode.value) {
+ if (ua == null && effectiveDesktopMode(tab)) {
ua = Config.DESKTOP_UA
}
if (ua?.contains("Browser/1.0 ") == true) {
+ // A UA from an older release that no longer applies: drop it rather than keep shipping it.
config.userAgentString.value = null
- ua = if (config.desktopMode.value) Config.DESKTOP_UA else null
+ ua = if (effectiveDesktopMode(tab)) Config.DESKTOP_UA else null
}
+ tab.webEngine.setDesktopModeOverride(tab.desktopMode ?: config.desktopMode.value)
tab.webEngine.userAgentString = ua
return webView
@@ -265,11 +315,11 @@ internal fun MainActivity.createWebView(tab: WebTabState): View? {
internal fun MainActivity.onWebViewUpdated(tab: WebTabState) {
if (tab != tabsModel.currentTab.value) return
- val isHome = tab.url == settingsModel.homePage || tab.url == Config.HOME_PAGE_URL || tab.url == Config.HOME_URL_ALIAS || tab.url.isEmpty() || tab.url == "about:blank"
+ val isHome = isHomeUrl(tab.url)
if (isHome) {
updateBackForwardButtons(
- canGoBack = false,
- canGoForward = !tab.lastUrlBeforeHome.isNullOrEmpty()
+ canGoBack = !tab.lastUrlBeforeHome.isNullOrEmpty(),
+ canGoForward = false
)
vb.vNativeHome.visibility = View.VISIBLE
vb.vNativeHome.bringToFront()
@@ -278,7 +328,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(
@@ -313,12 +363,14 @@ internal fun MainActivity.navigateInternal(url: String) {
}
val tab = tabsModel.currentTab.value
if (tab != null) {
- val wasOnHome = vb.vNativeHome.isVisible || tab.url == Config.HOME_URL_ALIAS || tab.url.isEmpty() || tab.url == settingsModel.homePage
- if (wasOnHome) {
- tab.webEngine.clearHistory()
- tab.webEngine.loadUrl("about:blank")
+ // Leaving the home screen is just hiding an overlay: the WebView stays alive, its history
+ // list is what it is (a page opened from Home can still be gone back from).
+ if (vb.vNativeHome.isVisible) {
+ vb.vNativeHome.visibility = View.GONE
+ vb.flWebViewContainer.visibility = View.VISIBLE
}
tab.lastUrlBeforeHome = null
+ tab.lastTitleBeforeHome = null
tab.url = url
vb.progressBarGeneric.visibility = View.VISIBLE
tab.webEngine.loadUrl(url)
@@ -328,9 +380,14 @@ internal fun MainActivity.navigateInternal(url: String) {
}
}
-internal fun MainActivity.navigateBackInternal(goHomeIfNoHistory: Boolean = false) {
- val currentTab = tabsModel.currentTab.value
- if (currentTab != null && currentTab.webEngine.canGoBack()) {
+internal fun MainActivity.navigateBackInternal() {
+ val currentTab = tabsModel.currentTab.value ?: return
+ // Home sits on top of the page, so Back reveals the page again instead of reloading it.
+ if (isHomeScreenShown()) {
+ hideHomeScreen()
+ return
+ }
+ if (currentTab.webEngine.canGoBack()) {
currentTab.webEngine.goBack()
} else {
showHomeScreen()
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/MainActivitySearch.kt b/app/src/main/java/com/gothwad/browser/activity/main/MainActivitySearch.kt
index fc50b63d..3467d7b9 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/MainActivitySearch.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/MainActivitySearch.kt
@@ -29,21 +29,14 @@ fun MainActivity.handleSearch(aText: String) {
}
}
-fun MainActivity.isCurrentTabInDesktopMode(): Boolean {
- val currentTab = tabsModel.currentTab.value
- val engineUa = currentTab?.webEngine?.userAgentString ?: config.userAgentString.value
- return config.isDesktopMode(engineUa)
-}
+fun MainActivity.isCurrentTabInDesktopMode(): Boolean = effectiveDesktopMode(tabsModel.currentTab.value)
fun MainActivity.applyWebPageZoom(percent: Int, targetIsDesktop: Boolean? = null) {
val clamped = percent.coerceIn(Config.WEB_PAGE_ZOOM_PERCENT_MIN, Config.WEB_PAGE_ZOOM_PERCENT_MAX)
val isDesktop = targetIsDesktop ?: isCurrentTabInDesktopMode()
config.setEffectiveZoom(isDesktop, clamped)
tabsModel.tabsStates.forEach { tab ->
- val tabIsDesktop = tab.webEngine.userAgentString?.let { ua ->
- config.isDesktopUa(ua)
- } ?: config.isDesktopMode()
- if (tabIsDesktop == isDesktop) {
+ if (effectiveDesktopMode(tab) == isDesktop) {
tab.webEngine.setPageZoom(clamped)
}
}
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..58d49260 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) {
@@ -69,19 +122,32 @@ internal fun MainActivity.setupHeaderClickListeners(incognitoMode: Boolean) {
vb.tvTabCountBadge.setOnClickListener { showTabsRowDialog() }
vb.ibBack.setOnClickListener { navigateBack() }
vb.ibForward.setOnClickListener {
+ // Forward is the WebView's own forward list only. It used to "restore" the page that Home
+ // had covered, which was a fake forward entry that re-fetched the page from the network.
val tab = tabsModel.currentTab.value ?: return@setOnClickListener
- val isHome = vb.vNativeHome.isVisible || tab.url == Config.HOME_URL_ALIAS || tab.url.isEmpty()
- if (isHome && !tab.lastUrlBeforeHome.isNullOrEmpty()) {
- val restoreUrl = tab.lastUrlBeforeHome!!
- tab.lastUrlBeforeHome = null
- navigate(restoreUrl)
- } else if (tab.webEngine.canGoForward()) {
+ if (tab.webEngine.canGoForward()) {
tab.webEngine.goForward()
}
}
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 +207,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 +450,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/MainActivityWebCallback.kt b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityWebCallback.kt
index 1959fc40..a24e9ee3 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/MainActivityWebCallback.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/MainActivityWebCallback.kt
@@ -174,6 +174,10 @@ internal class WebEngineCallback(val activity: MainActivity, val tab: WebTabStat
val webViewUrl = tab.webEngine.url
tab.url = webViewUrl ?: (url ?: "")
if (isCurrent) {
+ // A real navigation replaces whatever page Home was covering, so the restore marker
+ // must not survive it (otherwise Back would reveal a page that no longer exists).
+ tab.lastUrlBeforeHome = null
+ tab.lastTitleBeforeHome = null
activity.vb.vActionBar.setAddressBoxText(tab.url)
}
tab.blockedAds = 0
@@ -193,14 +197,22 @@ internal class WebEngineCallback(val activity: MainActivity, val tab: WebTabStat
if (isCurrent) {
activity.vb.vActionBar.setAddressBoxText(tab.url)
}
- thumbnailJob?.cancel()
- thumbnailJob = activity.lifecycleScope.launch {
- try {
- val newThumbnail = tab.webEngine.renderThumbnail(tab.thumbnail)
- if (newThumbnail != null) {
- tab.updateThumbnail(activity, newThumbnail)
+ // Only the tab on screen is worth a snapshot: a detached background WebView has no size, so
+ // the draw would be wasted, and the tab grid can live with the last thumbnail it captured.
+ val resumable = activity.lifecycle.currentState
+ .isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)
+ if (isCurrent && resumable) {
+ thumbnailJob?.cancel()
+ thumbnailJob = activity.lifecycleScope.launch {
+ try {
+ val newThumbnail = tab.webEngine.renderThumbnail(tab.thumbnail)
+ if (newThumbnail != null) {
+ tab.updateThumbnail(activity, newThumbnail)
+ }
+ } catch (e: Exception) {
+ // A failed snapshot must never break page loading.
}
- } catch (e: Exception) {}
+ }
}
}
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/TabsModel.kt b/app/src/main/java/com/gothwad/browser/activity/main/TabsModel.kt
index 7b4845d4..c1c3d1a9 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/TabsModel.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/TabsModel.kt
@@ -16,7 +16,6 @@ import com.gothwad.browser.utils.observable.ObservableValue
import com.gothwad.browser.webengine.WebEngineWindowProviderCallback
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.net.URL
@@ -90,6 +89,23 @@ class TabsModel : ActiveModel() {
loaded = true
}
+ /**
+ * Persist only the lightweight per-tab flags (currently: desktop-mode override) without
+ * touching the WebView state file, so a menu tap does not do any disk I/O on the UI thread.
+ */
+ fun persistDesktopMode(tab: WebTabState) {
+ if (tab.id == 0L) {
+ return
+ }
+ modelScope.launch(Dispatchers.IO) {
+ try {
+ AppDatabase.db.tabsDao().update(tab)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to persist desktop-mode flag: $e")
+ }
+ }
+ }
+
suspend fun saveTab(tab: WebTabState) = withContext(Dispatchers.IO) {
try {
val tabsDB = AppDatabase.db.tabsDao()
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/BrowserSidebarPopup.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/BrowserSidebarPopup.kt
index 7e4770c3..4b89b590 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/BrowserSidebarPopup.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/BrowserSidebarPopup.kt
@@ -20,6 +20,9 @@ import com.gothwad.browser.R
import com.gothwad.browser.activity.lock.AppLockActivity
import com.gothwad.browser.activity.lock.TvPinDialog
import com.gothwad.browser.activity.main.MainActivity
+import com.gothwad.browser.activity.main.desktopModeToastText
+import com.gothwad.browser.activity.main.isCurrentTabInDesktopMode
+import com.gothwad.browser.activity.main.toggleDesktopModeForCurrentTab
import com.gothwad.browser.activity.main.showDownloads
import com.gothwad.browser.activity.main.showFavoritesDialog
import com.gothwad.browser.activity.main.showHistoryActivity
@@ -177,7 +180,7 @@ class BrowserSidebarPopup(private val activity: MainActivity) {
}
activity.startActivity(Intent.createChooser(sendIntent, "Share URL"))
} else {
- Toast.makeText(activity, "No active page to share", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_no_active_page_to), Toast.LENGTH_SHORT).show()
}
}
}
@@ -205,22 +208,17 @@ class BrowserSidebarPopup(private val activity: MainActivity) {
// 7. Desktop View Mode
val ivDesktopCheck = contentView.findViewById(R.id.ivSideDesktopCheck)
- val isDesktop = config.isDesktopMode()
+ val isDesktop = activity.isCurrentTabInDesktopMode()
ivDesktopCheck?.setImageResource(
if (isDesktop) R.drawable.ic_check_box_checked else R.drawable.ic_check_box_outline
)
contentView.findViewById(R.id.btnSideDesktopMode)?.let { btn ->
bindItem(btn) {
- val willBeDesktop = !isDesktop
- config.desktopMode.value = willBeDesktop
- config.userAgentString.value = if (willBeDesktop) Config.DESKTOP_UA else null
- val newZoom = config.getEffectiveZoom(willBeDesktop)
- for (tab in activity.tabsModel.tabsStates) {
- tab.webEngine.userAgentString = if (willBeDesktop) Config.DESKTOP_UA else null
- tab.webEngine.setPageZoom(newZoom)
- }
- currentTab?.webEngine?.reload()
- Toast.makeText(activity, if (willBeDesktop) "Desktop mode enabled (${newZoom}% zoom)" else "Mobile mode enabled (${newZoom}% zoom)", Toast.LENGTH_SHORT).show()
+ val willBeDesktop = activity.toggleDesktopModeForCurrentTab(!isDesktop)
+ ivDesktopCheck?.setImageResource(
+ if (willBeDesktop) R.drawable.ic_check_box_checked else R.drawable.ic_check_box_outline
+ )
+ Toast.makeText(activity, activity.desktopModeToastText(willBeDesktop), Toast.LENGTH_SHORT).show()
}
}
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..764f9c16 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
@@ -23,7 +23,10 @@ import com.gothwad.browser.Config
import com.gothwad.browser.R
import com.gothwad.browser.activity.lock.AppLockActivity
import com.gothwad.browser.activity.main.MainActivity
+import com.gothwad.browser.activity.main.desktopModeToastText
+import com.gothwad.browser.activity.main.isCurrentTabInDesktopMode
import com.gothwad.browser.activity.main.openInNewTab
+import com.gothwad.browser.activity.main.toggleDesktopModeForCurrentTab
import com.gothwad.browser.activity.main.showDownloads
import com.gothwad.browser.activity.main.showFavoritesDialog
import com.gothwad.browser.activity.main.showHistoryActivity
@@ -116,7 +119,7 @@ class ChromeMenuPopup(private val activity: MainActivity) {
}
// 2. Forward
- val canGoForward = currentTab?.webEngine?.canGoForward() == true || (!currentTab?.lastUrlBeforeHome.isNullOrEmpty())
+ val canGoForward = currentTab?.webEngine?.canGoForward() == true
btnForward.isEnabled = canGoForward
btnForward.alpha = if (canGoForward) 1.0f else 0.4f
bindMenuItem(btnForward) {
@@ -145,16 +148,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()
}
}
@@ -169,7 +176,7 @@ class ChromeMenuPopup(private val activity: MainActivity) {
setDestinationInExternalPublicDir(android.os.Environment.DIRECTORY_DOWNLOADS, "page_${System.currentTimeMillis()}.html")
}
dm.enqueue(request)
- Toast.makeText(activity, "Download started...", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_download_started), Toast.LENGTH_SHORT).show()
} else {
activity.showDownloads(activity.vb.ibMenu)
}
@@ -247,7 +254,7 @@ class ChromeMenuPopup(private val activity: MainActivity) {
}
activity.startActivity(Intent.createChooser(sendIntent, "Share URL"))
} else {
- Toast.makeText(activity, "No active webpage to share", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_no_active_webpage_to), Toast.LENGTH_SHORT).show()
}
}
@@ -256,18 +263,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 +301,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()
}
}
@@ -312,32 +334,27 @@ class ChromeMenuPopup(private val activity: MainActivity) {
}
favDao.insert(newItem)
withContext(Dispatchers.Main) {
- Toast.makeText(activity, "Shortcut created on Home & Bookmarks: $title", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_shortcut_created, title), Toast.LENGTH_SHORT).show()
}
}
} else {
- Toast.makeText(activity, "No webpage to create shortcut for", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_no_webpage_to_create), Toast.LENGTH_SHORT).show()
}
}
// 13. Desktop Site Checkbox
val btnDesktop: View = contentView.findViewById(R.id.btnMenuDesktop)
val cbDesktop: CheckBox = contentView.findViewById(R.id.cbDesktopSite)
- val isDesktop = config.isDesktopMode()
- cbDesktop.isChecked = isDesktop
+ cbDesktop.isChecked = activity.isCurrentTabInDesktopMode()
bindMenuItem(btnDesktop) {
- val willBeDesktop = !cbDesktop.isChecked
+ val willBeDesktop = activity.toggleDesktopModeForCurrentTab(!cbDesktop.isChecked)
cbDesktop.isChecked = willBeDesktop
- config.desktopMode.value = willBeDesktop
- config.userAgentString.value = if (willBeDesktop) Config.DESKTOP_UA else null
- val newZoom = config.getEffectiveZoom(willBeDesktop)
- for (tab in activity.tabsModel.tabsStates) {
- tab.webEngine.userAgentString = if (willBeDesktop) Config.DESKTOP_UA else null
- 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.desktopModeToastText(willBeDesktop),
+ Toast.LENGTH_SHORT
+ ).show()
}
// 14. Settings
@@ -348,9 +365,12 @@ class ChromeMenuPopup(private val activity: MainActivity) {
// 15. Help & feedback
bindMenuItem(contentView.findViewById(R.id.btnMenuHelp)) {
AlertDialog.Builder(activity)
- .setTitle("Gothwad TV Browser")
- .setMessage("Android TV & Tablet Web Browser.\n\nVersion: ${BuildConfig.VERSION_NAME}\nDeveloper: gothwadtech@gmail.com\n\nFast, lightweight, ad-free browsing optimized for remote and touch.")
- .setPositiveButton("OK", null)
+ .setTitle(R.string.app_name)
+ .setMessage(activity.getString(R.string.menu_about_message, BuildConfig.VERSION_NAME))
+ .setPositiveButton(R.string.menu_about_website) { _, _ ->
+ activity.navigate("https://github.com/GothwadTech/browser")
+ }
+ .setNegativeButton(android.R.string.ok, null)
.show()
}
@@ -380,14 +400,14 @@ class ChromeMenuPopup(private val activity: MainActivity) {
"Clear everything (History, Cache & Cookies)"
)
AlertDialog.Builder(activity)
- .setTitle("Delete Browsing Data")
+ .setTitle(R.string.menu_delete_browsing_data)
.setItems(options) { _, which ->
when (which) {
0 -> {
CoroutineScope(Dispatchers.IO).launch {
AppDatabase.db.historyDao().deleteAll()
withContext(Dispatchers.Main) {
- Toast.makeText(activity, "Browsing history cleared", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_browsing_history_cleared), Toast.LENGTH_SHORT).show()
}
}
}
@@ -395,7 +415,7 @@ class ChromeMenuPopup(private val activity: MainActivity) {
CoroutineScope(Dispatchers.IO).launch {
WebEngineFactory.clearCache(activity)
withContext(Dispatchers.Main) {
- Toast.makeText(activity, "Cache & cookies cleared", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_cache_and_cookies_cleared), Toast.LENGTH_SHORT).show()
}
}
}
@@ -404,7 +424,7 @@ class ChromeMenuPopup(private val activity: MainActivity) {
AppDatabase.db.historyDao().deleteAll()
WebEngineFactory.clearCache(activity)
withContext(Dispatchers.Main) {
- Toast.makeText(activity, "All browsing data cleared", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_all_browsing_data_cleared), Toast.LENGTH_SHORT).show()
}
}
}
@@ -415,27 +435,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/DownloadsSidebarPopup.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/DownloadsSidebarPopup.kt
index 7f1d4fb4..ead4e8e2 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/DownloadsSidebarPopup.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/DownloadsSidebarPopup.kt
@@ -28,6 +28,7 @@ import com.gothwad.browser.R
import com.gothwad.browser.activity.downloads.ActiveDownloadsModel
import com.gothwad.browser.activity.main.MainActivity
import com.gothwad.browser.model.Download
+import com.gothwad.browser.utils.ListDiff
import com.gothwad.browser.singleton.AppDatabase
import com.gothwad.browser.utils.activemodel.ActiveModelsRepository
import kotlinx.coroutines.CoroutineScope
@@ -239,9 +240,14 @@ class DownloadsSidebarPopup(private val activity: MainActivity) : ActiveDownload
}
withContext(Dispatchers.Main) {
- downloadItems.clear()
- downloadItems.addAll(filtered)
- adapter.notifyDataSetChanged()
+ val downloadsAdapter = adapter
+ if (downloadsAdapter != null) {
+ ListDiff.submit(downloadsAdapter, downloadItems, filtered, idOf = { it.id },
+ contentOf = { "${it.filename}|${it.url}|${it.bytesReceived}|${it.size}" })
+ } else {
+ downloadItems.clear()
+ downloadItems.addAll(filtered)
+ }
tvDownloadsCountBadge.text = "${downloadItems.size} files"
llEmptyDownloads.visibility = if (downloadItems.isEmpty()) View.VISIBLE else View.GONE
@@ -253,7 +259,7 @@ class DownloadsSidebarPopup(private val activity: MainActivity) : ActiveDownload
private fun onDownloadClicked(download: Download) {
val file = File(download.filepath)
if (!file.exists()) {
- Toast.makeText(activity, "File not found on storage", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.downloads_file_not_found_on), Toast.LENGTH_SHORT).show()
return
}
@@ -270,9 +276,9 @@ class DownloadsSidebarPopup(private val activity: MainActivity) : ActiveDownload
}
activity.startActivity(openIntent)
} catch (e: ActivityNotFoundException) {
- Toast.makeText(activity, "No app found to open this file", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.downloads_no_app_found_to), Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
- Toast.makeText(activity, "Error opening file: ${e.message}", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.downloads_cannot_open, e.message ?: ""), Toast.LENGTH_SHORT).show()
}
}
@@ -330,7 +336,7 @@ class DownloadsSidebarPopup(private val activity: MainActivity) : ActiveDownload
}
class DownloadsSidebarAdapter(
- private val items: List,
+ private val items: MutableList,
private val onItemClick: (Download) -> Unit,
private val onDeleteClick: (Download) -> Unit
) : RecyclerView.Adapter() {
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/FavoritesSidebarPopup.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/FavoritesSidebarPopup.kt
index 99a6e871..7fd339f0 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/FavoritesSidebarPopup.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/FavoritesSidebarPopup.kt
@@ -25,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.gothwad.browser.R
import com.gothwad.browser.activity.main.MainActivity
import com.gothwad.browser.model.FavoriteItem
+import com.gothwad.browser.utils.ListDiff
import com.gothwad.browser.singleton.AppDatabase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -204,9 +205,16 @@ class FavoritesSidebarPopup(
}
}
- displayedFavorites.clear()
- displayedFavorites.addAll(filtered)
- adapter.notifyDataSetChanged()
+ // DiffUtil update instead of notifyDataSetChanged (AGENTS.md rule 5): a re-typed search box
+ // must not rebind the whole grid.
+ val favoritesAdapter = adapter
+ if (favoritesAdapter != null) {
+ ListDiff.submit(favoritesAdapter, displayedFavorites, filtered, idOf = { it.id },
+ contentOf = { "${it.title}|${it.url}|${it.favicon}" })
+ } else {
+ displayedFavorites.clear()
+ displayedFavorites.addAll(filtered)
+ }
llEmptyFavorites.visibility = if (displayedFavorites.isEmpty()) View.VISIBLE else View.GONE
rvFavGrid.visibility = if (displayedFavorites.isEmpty()) View.GONE else View.VISIBLE
@@ -229,7 +237,7 @@ class FavoritesSidebarPopup(
val url = currentTab?.url ?: ""
if (url.isBlank() || url == "about:blank") {
- Toast.makeText(activity, "No active page to bookmark", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.bookmarks_no_active_page_to), Toast.LENGTH_SHORT).show()
return
}
@@ -243,7 +251,7 @@ class FavoritesSidebarPopup(
val db = AppDatabase.db
db.favoritesDao().insert(item)
withContext(Dispatchers.Main) {
- Toast.makeText(activity, "Bookmark Added!", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.bookmarks_bookmark_added), Toast.LENGTH_SHORT).show()
loadBookmarks()
}
}
@@ -251,7 +259,7 @@ class FavoritesSidebarPopup(
}
class FavoritesSidebarAdapter(
- private val items: List,
+ private val items: MutableList,
private val onItemClick: (FavoriteItem) -> Unit,
private val onDeleteClick: (FavoriteItem) -> Unit
) : RecyclerView.Adapter() {
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/HistorySidebarPopup.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/HistorySidebarPopup.kt
index 6fa1c653..20e7cb95 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/HistorySidebarPopup.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/HistorySidebarPopup.kt
@@ -232,7 +232,7 @@ class HistorySidebarPopup(
btnResumeHistory.setOnClickListener {
activity.config.saveHistory = true
llHistoryPausedBanner.visibility = View.GONE
- Toast.makeText(activity, "Browsing history tracking enabled", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.history_browsing_history_tracking_enable), Toast.LENGTH_SHORT).show()
}
btnSelectAll.setOnClickListener {
@@ -249,8 +249,14 @@ class HistorySidebarPopup(
btnDeleteSelected.setOnClickListener {
if (selectedItemIds.isEmpty()) return@setOnClickListener
AlertDialog.Builder(activity)
- .setTitle("Delete Selected")
- .setMessage("Remove ${selectedItemIds.size} history items?")
+ .setTitle(R.string.history_delete_selected)
+ .setMessage(
+ activity.resources.getQuantityString(
+ R.plurals.history_remove_items_confirm,
+ selectedItemIds.size,
+ selectedItemIds.size
+ )
+ )
.setPositiveButton("Delete") { _, _ ->
deleteSelectedItems()
}
@@ -521,14 +527,14 @@ class HistorySidebarPopup(
2 -> {
item.url?.let { url ->
activity.openInNewTab(url)
- Toast.makeText(activity, "Opened in new tab", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.history_opened_in_new_tab), Toast.LENGTH_SHORT).show()
}
true
}
3 -> {
val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
clipboard?.setPrimaryClip(ClipData.newPlainText("URL", item.url ?: ""))
- Toast.makeText(activity, "Link copied to clipboard", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.history_link_copied_to_clipboard), Toast.LENGTH_SHORT).show()
true
}
4 -> {
@@ -562,7 +568,11 @@ class HistorySidebarPopup(
allHistoryItems.removeAll(itemsToDelete)
exitMultiselectMode()
rebuildDisplayedList()
- Toast.makeText(activity, "Deleted ${itemsToDelete.size} items", Toast.LENGTH_SHORT).show()
+ Toast.makeText(
+ activity,
+ activity.resources.getQuantityString(R.plurals.history_deleted_items, itemsToDelete.size, itemsToDelete.size),
+ Toast.LENGTH_SHORT
+ ).show()
}
}
}
@@ -574,7 +584,7 @@ class HistorySidebarPopup(
"Clear cache and cookies"
)
AlertDialog.Builder(activity)
- .setTitle("Clear Browsing Data")
+ .setTitle(R.string.history_clear_browsing_data)
.setItems(options) { _, which ->
when (which) {
0 -> clearAllHistory()
@@ -583,7 +593,7 @@ class HistorySidebarPopup(
CoroutineScope(Dispatchers.IO).launch {
com.gothwad.browser.webengine.WebEngineFactory.clearCache(activity)
withContext(Dispatchers.Main) {
- Toast.makeText(activity, "Cache & cookies cleared", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_cache_and_cookies_cleared), Toast.LENGTH_SHORT).show()
}
}
}
@@ -601,7 +611,7 @@ class HistorySidebarPopup(
selectedItemIds.clear()
exitMultiselectMode()
rebuildDisplayedList()
- Toast.makeText(activity, "All browsing history cleared", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.history_all_browsing_history_cleared), Toast.LENGTH_SHORT).show()
}
}
}
@@ -614,7 +624,7 @@ class HistorySidebarPopup(
withContext(Dispatchers.Main) {
allHistoryItems.removeAll(todayItems)
rebuildDisplayedList()
- Toast.makeText(activity, "Today's history cleared", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.history_today_s_history_cleared), Toast.LENGTH_SHORT).show()
}
}
}
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/dialogs/SearchEngineMenuPopup.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/SearchEngineMenuPopup.kt
index 15f6a8bc..640a1cdc 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/SearchEngineMenuPopup.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/SearchEngineMenuPopup.kt
@@ -153,7 +153,7 @@ class SearchEngineMenuPopup(private val activity: MainActivity) {
// Update search bar icon immediately to reflect new chosen engine
activity.vb.vActionBar.updateAddressBarIcon(enteredQuery)
- Toast.makeText(activity, "$engineName selected", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_search_engine_selected, engineName), Toast.LENGTH_SHORT).show()
etUrl?.requestFocus()
}
}
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/TabsSidebarPopup.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/TabsSidebarPopup.kt
index 4500368f..7fb771c3 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/TabsSidebarPopup.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/TabsSidebarPopup.kt
@@ -1,6 +1,7 @@
package com.gothwad.browser.activity.main.dialogs
import android.graphics.Color
+import com.gothwad.browser.utils.ListDiff
import android.graphics.drawable.ColorDrawable
import android.view.Gravity
import android.view.KeyEvent
@@ -211,10 +212,18 @@ class TabsSidebarAdapter(
}
fun update(newTabs: List, newCurrentTab: WebTabState?) {
- tabs.clear()
- tabs.addAll(newTabs)
+ val previousCurrent = currentTab
currentTab = newCurrentTab
- notifyDataSetChanged()
+ // The active tab is part of the row content, so the old and the new side get their own
+ // signature (otherwise switching tabs would not repaint the highlight).
+ ListDiff.submit(
+ this,
+ tabs,
+ newTabs,
+ idOf = { it.id },
+ contentOfOld = { "${it.title}|${it.url}|${if (it === previousCurrent) 1 else 0}" },
+ contentOfNew = { "${it.title}|${it.url}|${if (it === newCurrentTab) 1 else 0}" }
+ )
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/WebsiteMenuPopup.kt b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/WebsiteMenuPopup.kt
index 7911d407..fffe434a 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/dialogs/WebsiteMenuPopup.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/dialogs/WebsiteMenuPopup.kt
@@ -20,6 +20,9 @@ import com.gothwad.browser.Config
import com.gothwad.browser.R
import com.gothwad.browser.activity.main.MainActivity
import com.gothwad.browser.activity.main.applyWebPageZoom
+import com.gothwad.browser.activity.main.desktopModeToastText
+import com.gothwad.browser.activity.main.isCurrentTabInDesktopMode
+import com.gothwad.browser.activity.main.toggleDesktopModeForCurrentTab
import com.gothwad.browser.activity.main.openInNewTab
import com.gothwad.browser.activity.main.view.home.HomeCardAdapter
import com.gothwad.browser.activity.main.view.home.HomeData
@@ -92,9 +95,17 @@ class WebsiteMenuPopup(private val activity: MainActivity) {
val url = currentTab?.url ?: ""
val isSecure = url.startsWith("https://")
AlertDialog.Builder(activity)
- .setTitle(if (isSecure) "🔒 Secure Connection" else "ℹ️ Page Info")
- .setMessage("URL: ${if (url.isEmpty()) "Home Page" else url}\n\nSecurity: ${if (isSecure) "Encrypted Connection (HTTPS / SSL Active)" else "Unencrypted Connection (HTTP)"}\n\nCookies: Active\nJavaScript: Enabled")
- .setPositiveButton("OK", null)
+ .setTitle(if (isSecure) R.string.menu_site_info_title_secure else R.string.menu_site_info_title_page_info)
+ .setMessage(
+ activity.getString(
+ R.string.menu_site_info_message,
+ if (url.isEmpty()) activity.getString(R.string.home_screen) else url,
+ activity.getString(
+ if (isSecure) R.string.menu_site_info_secure else R.string.menu_site_info_insecure
+ )
+ )
+ )
+ .setPositiveButton(android.R.string.ok, null)
.show()
}
@@ -114,12 +125,12 @@ class WebsiteMenuPopup(private val activity: MainActivity) {
)
)
NativeHomeView.saveUserBookmarks(activity, currentList)
- Toast.makeText(activity, "Added '$title' to Shortcuts", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_added_to_shortcuts, title), Toast.LENGTH_SHORT).show()
} else {
- Toast.makeText(activity, "Already in Shortcuts", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_already_in_shortcuts), Toast.LENGTH_SHORT).show()
}
} else {
- Toast.makeText(activity, "Cannot bookmark empty page", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_cannot_bookmark_empty_page), Toast.LENGTH_SHORT).show()
}
}
@@ -130,7 +141,7 @@ class WebsiteMenuPopup(private val activity: MainActivity) {
// Desktop Site Checkbox setup
val btnDesktop: View = contentView.findViewById(R.id.btnWebMenuDesktop)
val cbDesktop: CheckBox = contentView.findViewById(R.id.cbWebMenuDesktop)
- val isDesktop = config.isDesktopMode()
+ val isDesktop = activity.isCurrentTabInDesktopMode()
cbDesktop.isChecked = isDesktop
// 🔎 Website Zoom Controls
@@ -162,7 +173,7 @@ class WebsiteMenuPopup(private val activity: MainActivity) {
val isDesk = cbDesktop.isChecked
activity.applyWebPageZoom(100, isDesk)
updateZoomDisplay()
- Toast.makeText(activity, "Zoom reset to 100%", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_zoom_reset_to_100), Toast.LENGTH_SHORT).show()
}
// 1. New Tab
@@ -172,18 +183,10 @@ class WebsiteMenuPopup(private val activity: MainActivity) {
// 2. Desktop Site Checkbox
bindItem(btnDesktop) {
- val willBeDesktop = !cbDesktop.isChecked
+ val willBeDesktop = activity.toggleDesktopModeForCurrentTab(!cbDesktop.isChecked)
cbDesktop.isChecked = willBeDesktop
- config.desktopMode.value = willBeDesktop
- config.userAgentString.value = if (willBeDesktop) Config.DESKTOP_UA else null
- val newZoom = config.getEffectiveZoom(willBeDesktop)
- for (tab in activity.tabsModel.tabsStates) {
- tab.webEngine.userAgentString = if (willBeDesktop) Config.DESKTOP_UA else null
- tab.webEngine.setPageZoom(newZoom)
- }
- currentTab?.webEngine?.reload()
updateZoomDisplay()
- Toast.makeText(activity, if (willBeDesktop) "Desktop mode enabled (${newZoom}% zoom)" else "Mobile mode enabled (${newZoom}% zoom)", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.desktopModeToastText(willBeDesktop), Toast.LENGTH_SHORT).show()
}
// 3. Find In Page
@@ -202,7 +205,7 @@ class WebsiteMenuPopup(private val activity: MainActivity) {
}
activity.startActivity(Intent.createChooser(sendIntent, "Share URL"))
} else {
- Toast.makeText(activity, "No active page to share", Toast.LENGTH_SHORT).show()
+ Toast.makeText(activity, activity.getString(R.string.menu_no_active_page_to), Toast.LENGTH_SHORT).show()
}
}
}
@@ -218,13 +221,13 @@ class WebsiteMenuPopup(private val activity: MainActivity) {
}
AlertDialog.Builder(activity)
- .setTitle("🔍 Find in page")
+ .setTitle(R.string.menu_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()
+ Toast.makeText(activity, activity.getString(R.string.menu_searching_for, query), Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("Cancel", null)
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/view/ActionBar.kt b/app/src/main/java/com/gothwad/browser/activity/main/view/ActionBar.kt
index f90e631d..b806c563 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/view/ActionBar.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/view/ActionBar.kt
@@ -186,7 +186,9 @@ class ActionBar @JvmOverloads constructor(
"yahoo" -> R.drawable.ic_logo_yahoo
"yandex" -> R.drawable.ic_logo_yandex
"startpage" -> R.drawable.ic_logo_startpage
- else -> R.drawable.ic_logo_google
+ // A custom / unknown engine must not claim to be Google in the omnibox: use the neutral
+ // search glyph instead of the Google logo.
+ else -> R.drawable.ic_search
}
}
@@ -207,7 +209,7 @@ class ActionBar @JvmOverloads constructor(
isHttpsSecure = false
vb.ivLockIcon.setImageResource(R.drawable.ic_menu_info)
vb.ivLockIcon.imageTintList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.day_night_icon_color))
- vb.ivLockIcon.contentDescription = "Not secure"
+ vb.ivLockIcon.contentDescription = context.getString(R.string.connection_not_secure)
} else {
isHttpsSecure = false
val config = AppContext.provideConfig()
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..588f3502 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
@@ -17,7 +17,15 @@ import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Toast
+import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+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 +55,14 @@ 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
+
init {
orientation = VERTICAL
setBackgroundResource(R.color.top_bar_background)
@@ -103,19 +115,18 @@ class NativeHomeView @JvmOverloads constructor(
val storageFreeStr = SystemMonitorHelper.formatBytes(storageStats.freeBytes)
val storageUsedStr = SystemMonitorHelper.formatBytes(storageStats.usedBytes)
- val storageTotalStr = SystemMonitorHelper.formatBytes(storageStats.totalBytes)
return listOf(
HomeShortcutItem(
- title = SystemMonitorHelper.getFormattedTime(),
- subtitleText = SystemMonitorHelper.getTimeSubtitle(),
+ title = SystemMonitorHelper.getFormattedTime(context),
+ subtitleText = SystemMonitorHelper.getTimeSubtitle(context),
iconDrawableRes = R.drawable.ic_stat_time,
isDashboardCard = true,
dashboardType = "TIME"
),
HomeShortcutItem(
- title = SystemMonitorHelper.getFormattedDate(),
- subtitleText = SystemMonitorHelper.getCalendarSubtitle(),
+ title = SystemMonitorHelper.getFormattedDate(context),
+ subtitleText = SystemMonitorHelper.getCalendarSubtitle(context),
iconDrawableRes = R.drawable.ic_stat_calendar,
isDashboardCard = true,
dashboardType = "CALENDAR"
@@ -128,15 +139,15 @@ class NativeHomeView @JvmOverloads constructor(
dashboardType = "NETWORK"
),
HomeShortcutItem(
- title = "RAM: $ramUsedStr / $ramTotalStr (${ramStats.usedPercent}%)",
- subtitleText = "⚡ Click to Boost RAM",
+ title = context.getString(R.string.home_mem_card_title, ramUsedStr, ramTotalStr, ramStats.usedPercent),
+ subtitleText = context.getString(R.string.home_mem_card_subtitle),
iconDrawableRes = R.drawable.ic_stat_ram,
isDashboardCard = true,
dashboardType = "RAM"
),
HomeShortcutItem(
- title = "Disk: $storageFreeStr Free",
- subtitleText = "Used: $storageUsedStr / $storageTotalStr (${storageStats.usedPercent}%)",
+ title = context.getString(R.string.home_disk_card_title, storageFreeStr),
+ subtitleText = context.getString(R.string.home_disk_card_subtitle, storageUsedStr, storageStats.usedPercent),
iconDrawableRes = R.drawable.ic_stat_storage,
isDashboardCard = true,
dashboardType = "STORAGE"
@@ -231,74 +242,132 @@ 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(context),
+ timeSubtitle = SystemMonitorHelper.getTimeSubtitle(context),
+ dateTitle = SystemMonitorHelper.getFormattedDate(context),
+ dateSubtitle = SystemMonitorHelper.getCalendarSubtitle(context),
+ networkTitle = net.first,
+ networkSubtitle = net.second,
+ ramTitle = context.getString(
+ R.string.home_mem_card_title,
+ 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@launch
+ 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 = context.getString(R.string.home_mem_card_subtitle)
+ )
+ if (snapshot.storageFree.isNotEmpty()) {
+ bookmarkItems[5] = bookmarkItems[5].copy(
+ title = context.getString(R.string.home_disk_card_title, snapshot.storageFree),
+ subtitleText = context.getString(R.string.home_disk_card_subtitle, 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) {
@@ -315,35 +384,50 @@ class NativeHomeView @JvmOverloads constructor(
"TIME" -> showTimeInfoDialog()
"CALENDAR" -> showCalendarInfoDialog()
"NETWORK" -> showNetworkInfoDialog()
- "RAM" -> showRamBoostDialog()
+ "RAM" -> showMemoryPanel()
"STORAGE" -> showStorageInfoDialog()
}
}
private fun showTimeInfoDialog() {
val cal = java.util.Calendar.getInstance()
- val timeFormatted = SystemMonitorHelper.getFormattedTime()
+ val timeFormatted = SystemMonitorHelper.getFormattedTime(context)
val timeZone = cal.timeZone.displayName ?: "Local Time"
val timeZoneId = cal.timeZone.id
AlertDialog.Builder(context)
- .setTitle("🕒 System Time")
- .setMessage("Current Time: $timeFormatted\nTimezone: $timeZone ($timeZoneId)\nLive clock updates automatically.")
- .setPositiveButton("OK", null)
+ .setTitle(R.string.home_system_time)
+ .setMessage(
+ context.getString(
+ R.string.home_time_dialog_message,
+ timeFormatted,
+ timeZone,
+ timeZoneId
+ )
+ )
+ .setPositiveButton(android.R.string.ok, null)
.show()
}
private fun showCalendarInfoDialog() {
- val dateFormatted = SystemMonitorHelper.getFormattedDate()
- val subtitle = SystemMonitorHelper.getCalendarSubtitle()
+ val dateFormatted = SystemMonitorHelper.getFormattedDate(context)
+ val subtitle = SystemMonitorHelper.getCalendarSubtitle(context)
val cal = java.util.Calendar.getInstance()
val year = cal.get(java.util.Calendar.YEAR)
val isLeapYear = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
AlertDialog.Builder(context)
- .setTitle("📅 System Calendar")
- .setMessage("Date: $dateFormatted\nProgress: $subtitle\nYear: $year (Leap year: ${if (isLeapYear) "Yes" else "No"})")
- .setPositiveButton("OK", null)
+ .setTitle(R.string.home_system_calendar)
+ .setMessage(
+ context.getString(
+ R.string.home_calendar_dialog_message,
+ dateFormatted,
+ subtitle,
+ year,
+ context.getString(if (isLeapYear) R.string.yes else R.string.no)
+ )
+ )
+ .setPositiveButton(android.R.string.ok, null)
.show()
}
@@ -356,34 +440,114 @@ class NativeHomeView @JvmOverloads constructor(
val totalTx = SystemMonitorHelper.formatBytes(android.net.TrafficStats.getTotalTxBytes())
AlertDialog.Builder(context)
- .setTitle("🌐 Network & Speed Monitor")
- .setMessage("Status: ${netInfo.first}\n\nReal-Time Speed:\n• Download: $downSpeed\n• Upload: $upSpeed\n\nTotal Session Traffic:\n• Total Received: $totalRx\n• Total Sent: $totalTx\n\n(Monitored directly via device network stats without consuming internet)")
- .setPositiveButton("OK", null)
+ .setTitle(R.string.home_network_and_speed_monitor)
+ .setMessage(
+ context.getString(
+ R.string.home_network_dialog_message,
+ netInfo.first,
+ downSpeed,
+ upSpeed,
+ totalRx,
+ totalTx
+ )
+ )
+ .setPositiveButton(android.R.string.ok, null)
.show()
}
- private fun showRamBoostDialog() {
- val freed = SystemMonitorHelper.performRamBoost(context)
- val ramStats = SystemMonitorHelper.getRamStats(context)
- val used = SystemMonitorHelper.formatBytes(ramStats.usedBytes)
- val total = SystemMonitorHelper.formatBytes(ramStats.totalBytes)
- val avail = SystemMonitorHelper.formatBytes(ramStats.availBytes)
-
- updateDashboardCardsLive()
-
- val freedText = if (freed > 0) "${SystemMonitorHelper.formatBytes(freed)} freed!" else "Memory already optimized!"
- Toast.makeText(context, "⚡ RAM Boosted: $freedText", Toast.LENGTH_SHORT).show()
+ /**
+ * Memory panel.
+ *
+ * This replaces the old "Boost RAM" dialog, which called `System.gc()` and then reported the
+ * delta as memory "freed" - a number that meant nothing (the OS reclaims what it wants anyway)
+ * and that no user can act on. The panel instead shows the real figures and offers the only two
+ * actions that genuinely release memory in this app: releasing detached background page views
+ * (the tab and its history stay) and clearing the WebView cache.
+ */
+ private fun showMemoryPanel() {
+ val main = context as? MainActivity
+ val ram = SystemMonitorHelper.getRamStats(context)
+ val runtime = Runtime.getRuntime()
+ val heapUsed = (runtime.totalMemory() - runtime.freeMemory()).coerceAtLeast(0L)
+ val tabs = main?.tabsModel?.tabsStates.orEmpty()
+ val liveViews = tabs.count { it.webEngine.getView() != null }
+ val maxLive = com.gothwad.browser.AppContext.provideConfig().maxLiveTabs
+
+ val message = buildString {
+ append(
+ context.getString(
+ R.string.home_mem_device,
+ SystemMonitorHelper.formatBytes(ram.usedBytes),
+ SystemMonitorHelper.formatBytes(ram.totalBytes),
+ ram.usedPercent,
+ SystemMonitorHelper.formatBytes(ram.availBytes)
+ )
+ )
+ append("\n\n")
+ append(
+ context.getString(
+ R.string.home_mem_app,
+ SystemMonitorHelper.formatBytes(heapUsed),
+ SystemMonitorHelper.formatBytes(runtime.maxMemory())
+ )
+ )
+ if (tabs.isNotEmpty()) {
+ append("\n\n")
+ append(context.getString(R.string.home_mem_tabs, liveViews, tabs.size, maxLive))
+ }
+ append("\n\n")
+ append(context.getString(R.string.home_mem_note))
+ }
AlertDialog.Builder(context)
- .setTitle("⚡ RAM & Performance Status")
- .setMessage("RAM Optimizer Result: $freedText\n\n• Used RAM: $used (${ramStats.usedPercent}%)\n• Available Free RAM: $avail\n• Total RAM: $total\n\nBrowser cache and memory trimmed safely.")
- .setPositiveButton("OK", null)
- .setNeutralButton("Boost Again") { _, _ ->
- showRamBoostDialog()
+ .setTitle(R.string.home_mem_dialog_title)
+ .setMessage(message)
+ .setPositiveButton(R.string.home_mem_trim) { _, _ ->
+ releaseBackgroundPageViews()
+ }
+ .setNegativeButton(R.string.home_mem_cache) { _, _ ->
+ clearBrowserCache()
}
+ .setNeutralButton(android.R.string.cancel, null)
.show()
}
+ private fun releaseBackgroundPageViews() {
+ val main = context as? MainActivity
+ val model = main?.tabsModel
+ if (model == null) {
+ return
+ }
+ val before = model.tabsStates.count { it.webEngine.getView() != null }
+ model.onTrimMemory()
+ val after = model.tabsStates.count { it.webEngine.getView() != null }
+ val released = (before - after).coerceAtLeast(0)
+ Toast.makeText(
+ context,
+ if (released > 0) {
+ context.getString(R.string.home_mem_trimmed, released)
+ } else {
+ context.getString(R.string.home_mem_nothing_to_trim)
+ },
+ Toast.LENGTH_SHORT
+ ).show()
+ updateDashboardCardsLive()
+ }
+
+ private fun clearBrowserCache() {
+ val main = context as? MainActivity
+ val scope = main?.lifecycleScope ?: return
+ scope.launch {
+ try {
+ com.gothwad.browser.webengine.WebEngineFactory.clearCache(context)
+ Toast.makeText(context, R.string.home_mem_cache_cleared, Toast.LENGTH_SHORT).show()
+ } catch (e: Throwable) {
+ Toast.makeText(context, e.localizedMessage ?: "cache", Toast.LENGTH_SHORT).show()
+ }
+ }
+ updateDashboardCardsLive()
+ }
+
private fun showStorageInfoDialog() {
val storage = SystemMonitorHelper.getStorageStats()
val total = SystemMonitorHelper.formatBytes(storage.totalBytes)
@@ -392,9 +556,21 @@ class NativeHomeView @JvmOverloads constructor(
val cores = Runtime.getRuntime().availableProcessors()
AlertDialog.Builder(context)
- .setTitle("💾 Storage & Device Info")
- .setMessage("Internal Storage:\n• Free: $free\n• Used: $used (${storage.usedPercent}%)\n• Total: $total\n\nDevice & CPU Info:\n• Device: ${Build.MANUFACTURER} ${Build.MODEL}\n• Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n• CPU Cores: $cores Cores")
- .setPositiveButton("OK", null)
+ .setTitle(R.string.home_storage_and_device_info)
+ .setMessage(
+ context.getString(
+ R.string.home_storage_dialog_message,
+ free,
+ used,
+ storage.usedPercent,
+ total,
+ "${Build.MANUFACTURER} ${Build.MODEL}",
+ Build.VERSION.RELEASE,
+ Build.VERSION.SDK_INT,
+ cores
+ )
+ )
+ .setPositiveButton(android.R.string.ok, null)
.show()
}
@@ -519,7 +695,7 @@ class NativeHomeView @JvmOverloads constructor(
saveUserBookmarks(currentList)
reloadShortcuts()
- Toast.makeText(context, "Shortcut '$name' added!", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, context.getString(R.string.home_shortcut_added, name), Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("Cancel", null)
@@ -529,14 +705,14 @@ class NativeHomeView @JvmOverloads constructor(
private fun showRemoveSelectionDialog() {
val userShortcuts = loadUserBookmarks()
if (userShortcuts.isEmpty()) {
- Toast.makeText(context, "No manual shortcuts to remove", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, context.getString(R.string.home_no_manual_shortcuts_to), Toast.LENGTH_SHORT).show()
return
}
val shortcutTitles = userShortcuts.map { "${it.title} (${it.domainText})" }.toTypedArray()
AlertDialog.Builder(context)
- .setTitle("Select Shortcut to Remove")
+ .setTitle(R.string.home_select_shortcut_to_remove)
.setItems(shortcutTitles) { _, which ->
if (which in userShortcuts.indices) {
val target = userShortcuts[which]
@@ -549,13 +725,13 @@ class NativeHomeView @JvmOverloads constructor(
private fun showDeleteBookmarkDialog(item: HomeShortcutItem) {
AlertDialog.Builder(context)
- .setTitle("Delete Shortcut")
- .setMessage("Remove '${item.title}' from My Shortcuts?")
+ .setTitle(R.string.home_delete_shortcut)
+ .setMessage(context.getString(R.string.home_remove_shortcut_confirm, item.title ?: ""))
.setPositiveButton("Delete") { _, _ ->
val userOnly = loadUserBookmarks().filter { it.url != item.url || it.title != item.title }
saveUserBookmarks(userOnly)
reloadShortcuts()
- Toast.makeText(context, "'${item.title}' deleted", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, context.getString(R.string.home_shortcut_removed, item.title ?: ""), Toast.LENGTH_SHORT).show()
}
.setNegativeButton("Cancel", null)
.show()
@@ -782,6 +958,9 @@ class NativeHomeView @JvmOverloads constructor(
}
companion object {
+ private const val DASHBOARD_TICK_MS = 1000L
+ private const val STORAGE_TICK_EVERY = 5L
+
private const val PREFS_NAME = "native_home_shortcuts_v11"
private const val KEY_BOOKMARKS = "bookmarks_json"
diff --git a/app/src/main/java/com/gothwad/browser/activity/main/view/home/SystemMonitorHelper.kt b/app/src/main/java/com/gothwad/browser/activity/main/view/home/SystemMonitorHelper.kt
index 28d3ae13..e60b2b20 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/view/home/SystemMonitorHelper.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/view/home/SystemMonitorHelper.kt
@@ -1,6 +1,7 @@
package com.gothwad.browser.activity.main.view.home
import android.app.ActivityManager
+import com.gothwad.browser.R
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
@@ -80,41 +81,41 @@ object SystemMonitorHelper {
}
}
- fun getFormattedTime(): String {
+ fun getFormattedTime(context: Context): String {
return try {
val sdf = SimpleDateFormat("hh:mm:ss a", Locale.getDefault())
sdf.format(Date())
} catch (e: Throwable) {
- "Time"
+ context.getString(R.string.home_time_fallback)
}
}
- fun getTimeSubtitle(): String {
+ fun getTimeSubtitle(context: Context): String {
return try {
val cal = Calendar.getInstance()
- cal.timeZone.displayName ?: "Local Time"
+ cal.timeZone.displayName ?: context.getString(R.string.home_local_time)
} catch (e: Throwable) {
- "Local Time"
+ context.getString(R.string.home_local_time)
}
}
- fun getFormattedDate(): String {
+ fun getFormattedDate(context: Context): String {
return try {
val sdf = SimpleDateFormat("EEE, dd MMM yyyy", Locale.getDefault())
sdf.format(Date())
} catch (e: Throwable) {
- "Date"
+ context.getString(R.string.home_date_fallback)
}
}
- fun getCalendarSubtitle(): String {
+ fun getCalendarSubtitle(context: Context): String {
return try {
val cal = Calendar.getInstance()
val dayOfYear = cal.get(Calendar.DAY_OF_YEAR)
val weekOfYear = cal.get(Calendar.WEEK_OF_YEAR)
- "Day $dayOfYear • Week $weekOfYear"
+ context.getString(R.string.home_calendar_day_week, dayOfYear, weekOfYear)
} catch (e: Throwable) {
- "Calendar"
+ context.getString(R.string.home_calendar_fallback)
}
}
@@ -122,27 +123,39 @@ object SystemMonitorHelper {
try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
if (cm == null) {
- return Pair("Offline", "No Network")
+ return Pair(
+ context.getString(R.string.home_network_offline),
+ context.getString(R.string.home_network_none)
+ )
}
val activeNetwork = cm.activeNetwork
val capabilities = cm.getNetworkCapabilities(activeNetwork)
if (capabilities == null || !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
- return Pair("Offline", "No Internet")
+ return Pair(
+ context.getString(R.string.home_network_offline),
+ context.getString(R.string.home_network_no_internet)
+ )
}
val typeStr: String = when {
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi Connected"
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet Connected"
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile Data"
- else -> "Online"
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ->
+ context.getString(R.string.home_network_wifi)
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ->
+ context.getString(R.string.home_network_ethernet)
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ->
+ context.getString(R.string.home_network_cellular)
+ else -> context.getString(R.string.home_network_online)
}
val detailStr = "↓ $currentDownloadSpeedStr ↑ $currentUploadSpeedStr"
return Pair(typeStr, detailStr)
} catch (e: Throwable) {
- return Pair("Online", "↓ $currentDownloadSpeedStr ↑ $currentUploadSpeedStr")
+ return Pair(
+ context.getString(R.string.home_network_online),
+ "↓ $currentDownloadSpeedStr ↑ $currentUploadSpeedStr"
+ )
}
}
@@ -219,15 +232,4 @@ object SystemMonitorHelper {
}
}
- fun performRamBoost(context: Context): Long {
- return try {
- val before = getRamStats(context).availBytes
- System.gc()
- Runtime.getRuntime().gc()
- val after = getRamStats(context).availBytes
- (after - before).coerceAtLeast(0)
- } catch (e: Throwable) {
- 0L
- }
- }
}
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/activity/main/view/tabs/ChromeTabSwitcherOverlay.kt b/app/src/main/java/com/gothwad/browser/activity/main/view/tabs/ChromeTabSwitcherOverlay.kt
index cadb14a8..8cd8249b 100644
--- a/app/src/main/java/com/gothwad/browser/activity/main/view/tabs/ChromeTabSwitcherOverlay.kt
+++ b/app/src/main/java/com/gothwad/browser/activity/main/view/tabs/ChromeTabSwitcherOverlay.kt
@@ -1,6 +1,7 @@
package com.gothwad.browser.activity.main.view.tabs
import android.content.Context
+import com.gothwad.browser.utils.ListDiff
import android.graphics.Color
import android.net.Uri
import android.text.Editable
@@ -209,10 +210,16 @@ class ChromeTabGridAdapter(
private var activeTab: WebTabState? = null
fun submitList(tabs: List, current: WebTabState?) {
- items.clear()
- items.addAll(tabs)
+ val previousActive = activeTab
activeTab = current
- notifyDataSetChanged()
+ ListDiff.submit(
+ this,
+ items,
+ tabs,
+ idOf = { it.id },
+ contentOfOld = { "${it.title}|${it.url}|${if (it === previousActive) 1 else 0}|${it.thumbnail}" },
+ contentOfNew = { "${it.title}|${it.url}|${if (it === current) 1 else 0}|${it.thumbnail}" }
+ )
}
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
diff --git a/app/src/main/java/com/gothwad/browser/service/downloads/DownloadService.kt b/app/src/main/java/com/gothwad/browser/service/downloads/DownloadService.kt
index 902f9c25..265b9792 100644
--- a/app/src/main/java/com/gothwad/browser/service/downloads/DownloadService.kt
+++ b/app/src/main/java/com/gothwad/browser/service/downloads/DownloadService.kt
@@ -106,11 +106,11 @@ class DownloadService : Service() {
notificationBuilder = NotificationCompat.Builder(this, BrowserApp.CHANNEL_ID_DOWNLOADS)
.setOngoing(true)
.setOnlyAlertOnce(true)
- .setSmallIcon(R.mipmap.ic_launcher)
+ .setSmallIcon(R.drawable.ic_file_download_grey_900)
}
notificationBuilder!!.setContentTitle(title)
.setContentText(description)
- .setSmallIcon(R.mipmap.ic_launcher)
+ .setSmallIcon(R.drawable.ic_file_download_grey_900)
if (hasUnknownSizedFiles || total == 0L) {
notificationBuilder!!.setProgress(0, 0, true)
} else {
diff --git a/app/src/main/java/com/gothwad/browser/service/downloads/DownloadTask.kt b/app/src/main/java/com/gothwad/browser/service/downloads/DownloadTask.kt
index 8506c09b..fc279d96 100644
--- a/app/src/main/java/com/gothwad/browser/service/downloads/DownloadTask.kt
+++ b/app/src/main/java/com/gothwad/browser/service/downloads/DownloadTask.kt
@@ -61,8 +61,16 @@ class FileDownloadTask(override var downloadInfo: Download, private val userAgen
val cookie = CookieManager.getInstance().getCookie(url.toString())
if (cookie != null) setRequestProperty("cookie", cookie)
if (retries > 0) {
- //trust me, sometimes this helps! Don't ask me how...
- Thread.sleep(3000)
+ // Bounded backoff before reconnecting: retrying a gateway timeout instantly
+ // only hammers a server that is already saturated. This runs on the download
+ // thread pool (never the UI thread) and stays responsive to interrupts.
+ val backoffMs = minOf(500L * (1L shl (retries - 1).coerceAtMost(3)), 4000L)
+ try {
+ Thread.sleep(backoffMs) // quality-gate: allow
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw e
+ }
}
connect()
}
diff --git a/app/src/main/java/com/gothwad/browser/service/keepalive/BrowserKeepAliveService.kt b/app/src/main/java/com/gothwad/browser/service/keepalive/BrowserKeepAliveService.kt
index 8f16b8c0..d8ec0059 100644
--- a/app/src/main/java/com/gothwad/browser/service/keepalive/BrowserKeepAliveService.kt
+++ b/app/src/main/java/com/gothwad/browser/service/keepalive/BrowserKeepAliveService.kt
@@ -125,7 +125,7 @@ class BrowserKeepAliveService : Service() {
)
return NotificationCompat.Builder(this, CHANNEL_ID_KEEPALIVE)
- .setSmallIcon(R.mipmap.ic_launcher)
+ .setSmallIcon(R.drawable.ic_stat_network)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.background_keepalive_desc))
.setPriority(NotificationCompat.PRIORITY_MIN)
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..eeb93f0f 100644
--- a/app/src/main/java/com/gothwad/browser/settings/MainSettingsView.kt
+++ b/app/src/main/java/com/gothwad/browser/settings/MainSettingsView.kt
@@ -10,6 +10,7 @@ import androidx.lifecycle.lifecycleScope
import com.gothwad.browser.AppContext
import com.gothwad.browser.Config
import com.gothwad.browser.activity.main.MainActivity
+import com.gothwad.browser.activity.main.applyDesktopModeDefault
import com.gothwad.browser.databinding.ViewSettingsMainBinding
import com.gothwad.browser.utils.activemodel.ActiveModelsRepository
import com.gothwad.browser.utils.activity
@@ -31,6 +32,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)
@@ -61,6 +63,12 @@ class MainSettingsView @JvmOverloads constructor(
settingsModel.keepScreenOn.value = isChecked
}
+ vb.scDesktopModeDefault.isChecked = config.desktopMode.value
+ vb.scDesktopModeDefault.setOnCheckedChangeListener { _, isChecked ->
+ config.desktopMode.value = isChecked
+ (activity as? MainActivity)?.applyDesktopModeDefault()
+ }
+
vb.scDisableVirtualKeyboard.isChecked = config.disableVirtualKeyboard
vb.scDisableVirtualKeyboard.setOnCheckedChangeListener { _, isChecked ->
config.disableVirtualKeyboard = isChecked
diff --git a/app/src/main/java/com/gothwad/browser/settings/SettingsDialog.kt b/app/src/main/java/com/gothwad/browser/settings/SettingsDialog.kt
index 3f65bceb..60cbb3aa 100644
--- a/app/src/main/java/com/gothwad/browser/settings/SettingsDialog.kt
+++ b/app/src/main/java/com/gothwad/browser/settings/SettingsDialog.kt
@@ -14,6 +14,8 @@ import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import com.gothwad.browser.R
+import com.gothwad.browser.activity.main.MainActivity
+import com.gothwad.browser.activity.main.bindInputModeSegments
class SettingsDialog(private val context: Context, val model: SettingsModel) :
VersionSettingsView.Callback {
@@ -56,6 +58,29 @@ class SettingsDialog(private val context: Context, val model: SettingsModel) :
private var lastFocusedCategoryItem: View? = null
+ companion object {
+ /** title resource -> subtitle resource, so the search box also matches the subtitle text. */
+ private val categorySubtitleRes = mapOf(
+ R.string.engine to R.string.settings_google_duckduckgo_bing_custom,
+ R.string.settings_display_and_ui_scale to R.string.settings_interface_scale_orientation_and,
+ R.string.settings_web_page_zoom to R.string.settings_default_page_magnification_and,
+ R.string.settings_themes_and_appearance to R.string.settings_dark_mode_amoled_black,
+ R.string.settings_media_playback_and_video to R.string.settings_autoplay_media_background_audio,
+ R.string.settings_home_page_and_startup to R.string.settings_new_tab_grid_custom,
+ R.string.settings_user_agent_and_identity to R.string.settings_desktop_mode_mobile_browser,
+ R.string.settings_web_engine_and_rendering to R.string.settings_system_webview_gpu_acceleration,
+ R.string.settings_ad_and_tracker_blocker to R.string.settings_easylist_filter_rules_auto,
+ R.string.settings_app_lock_and_pin to R.string.settings_passcode_protection_incognito_lock,
+ R.string.settings_cache_and_storage to R.string.settings_clear_browsing_history_cookies,
+ R.string.settings_quick_tools_and_utilities to R.string.settings_keep_screen_on_downloads,
+ R.string.settings_remote_and_joystick to R.string.settings_d_pad_navigation_analog,
+ R.string.settings_virtual_cursor_and_physics to R.string.settings_speed_acceleration_cursor_size,
+ R.string.settings_keyboard_and_mouse to R.string.settings_virtual_keyboard_pointer_customisation,
+ R.string.settings_keyboard_shortcuts to R.string.settings_configurable_hotkeys_for_tabs,
+ R.string.settings_about to R.string.settings_about_and_version
+ )
+ }
+
init {
rootContainer = object : FrameLayout(context) {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -107,6 +132,24 @@ class SettingsDialog(private val context: Context, val model: SettingsModel) :
bindViews()
initChildViews()
setupListeners()
+ setupInputModeRow()
+ }
+
+ /**
+ * The "how do you browse" segmented switch that sits above the search field: one place that
+ * turns TV-remote / keyboard+mouse / touch into the concrete pointer + page-layout switches.
+ */
+ private fun setupInputModeRow() {
+ val main = activity as? MainActivity ?: run {
+ contentView.findViewById(R.id.llInputModeRow)?.visibility = View.GONE
+ return
+ }
+ main.bindInputModeSegments(
+ auto = contentView.findViewById(R.id.btnInputModeAuto),
+ tvRemote = contentView.findViewById(R.id.btnInputModeTvRemote),
+ keyboardMouse = contentView.findViewById(R.id.btnInputModeKeyboardMouse),
+ touch = contentView.findViewById(R.id.btnInputModeTouch)
+ )
}
private fun bindViews() {
@@ -158,80 +201,95 @@ class SettingsDialog(private val context: Context, val model: SettingsModel) :
}
val etSearch = contentView.findViewById(R.id.etSearchSettings)
+ // Search over the same resources the rows display: one source of truth, and it becomes
+ // translatable for free (it used to match against hard-coded English keyword blobs).
val allCategoryItems = listOf(
- itemCatSearchEngine to "Search Engine",
- itemCatDisplayScale to "Display Scale Orientation",
- itemCatWebZoom to "Web Page Zoom Magnification",
- itemCatThemes to "Themes Appearance Dark Mode",
- itemCatMediaPlayback to "Media Autoplay Video",
- itemCatHomePage to "Home Page Startup",
- itemCatUserAgent to "User Agent Identity Desktop Mobile",
- itemCatWebEngine to "Web Engine Rendering Chromium",
- itemCatAdBlock to "AdBlock Ad Blocking Filter",
- itemCatAppLock to "App Lock PIN Security",
- itemCatCacheStorage to "Cache Storage Cookies Clear Data",
- itemCatQuickTools to "Quick Tools Toolbar Shortcuts",
- itemCatRemoteNav to "Remote Navigation D-pad Controls",
- itemCatCursorPhysics to "Virtual Cursor Speed Acceleration",
- itemCatKeyboardMouse to "Keyboard Mouse Hardware Input",
- itemCatShortcuts to "Keyboard Shortcuts Custom Bindings",
- itemCatAbout to "About Gothwad Browser Version Info"
+ itemCatSearchEngine to R.string.engine,
+ itemCatDisplayScale to R.string.settings_display_and_ui_scale,
+ itemCatWebZoom to R.string.settings_web_page_zoom,
+ itemCatThemes to R.string.settings_themes_and_appearance,
+ itemCatMediaPlayback to R.string.settings_media_playback_and_video,
+ itemCatHomePage to R.string.settings_home_page_and_startup,
+ itemCatUserAgent to R.string.settings_user_agent_and_identity,
+ itemCatWebEngine to R.string.settings_web_engine_and_rendering,
+ itemCatAdBlock to R.string.settings_ad_and_tracker_blocker,
+ itemCatAppLock to R.string.settings_app_lock_and_pin,
+ itemCatCacheStorage to R.string.settings_cache_and_storage,
+ itemCatQuickTools to R.string.settings_quick_tools_and_utilities,
+ itemCatRemoteNav to R.string.settings_remote_and_joystick,
+ itemCatCursorPhysics to R.string.settings_virtual_cursor_and_physics,
+ itemCatKeyboardMouse to R.string.settings_keyboard_and_mouse,
+ itemCatShortcuts to R.string.settings_keyboard_shortcuts,
+ itemCatAbout to R.string.settings_about
)
+ val categoryKeywords = allCategoryItems.map { (view, titleRes) ->
+ val subRes = categorySubtitleRes[titleRes] ?: 0
+ val text = buildString {
+ append(context.getString(titleRes))
+ if (subRes != 0) append(' ').append(context.getString(subRes))
+ }
+ view to text.lowercase(java.util.Locale.getDefault())
+ }
+
etSearch?.addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
val q = s?.toString()?.trim() ?: ""
- for ((view, text) in allCategoryItems) {
- view.visibility = if (q.isBlank() || text.contains(q, ignoreCase = true)) View.VISIBLE else View.GONE
+ for ((view, text) in categoryKeywords) {
+ view.visibility = if (q.isBlank() || text.contains(q.lowercase(java.util.Locale.getDefault()))) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
}
}
override fun afterTextChanged(s: android.text.Editable?) {}
})
bindCategoryItem(itemCatDisplayScale) {
- openMainCategory(itemCatDisplayScale, "Display & UI Scale", "Display & Layout", SettingsCategory.DISPLAY_SCALE)
+ openMainCategory(itemCatDisplayScale, R.string.settings_display_and_ui_scale, R.string.settings_interface_scale_orientation_and, SettingsCategory.DISPLAY_SCALE)
}
bindCategoryItem(itemCatWebZoom) {
- openMainCategory(itemCatWebZoom, "Web Page Zoom", "Zoom Controls", SettingsCategory.WEB_ZOOM)
+ openMainCategory(itemCatWebZoom, R.string.settings_web_page_zoom, R.string.settings_default_page_magnification_and, SettingsCategory.WEB_ZOOM)
}
bindCategoryItem(itemCatThemes) {
- openMainCategory(itemCatThemes, "Themes & Appearance", "Appearance & Dark Mode", SettingsCategory.THEMES)
+ openMainCategory(itemCatThemes, R.string.settings_themes_and_appearance, R.string.settings_dark_mode_amoled_black, SettingsCategory.THEMES)
}
bindCategoryItem(itemCatMediaPlayback) {
- openMainCategory(itemCatMediaPlayback, "Media & Playback", "Video & Audio", SettingsCategory.MEDIA_PLAYBACK)
+ openMainCategory(itemCatMediaPlayback, R.string.settings_media_playback_and_video, R.string.settings_autoplay_media_background_audio, SettingsCategory.MEDIA_PLAYBACK)
}
bindCategoryItem(itemCatSearchEngine) {
- openMainCategory(itemCatSearchEngine, "Search Engine", "Default Engine", SettingsCategory.SEARCH_ENGINE)
+ openMainCategory(itemCatSearchEngine, R.string.engine, R.string.settings_google_duckduckgo_bing_custom, SettingsCategory.SEARCH_ENGINE)
}
bindCategoryItem(itemCatHomePage) {
- openMainCategory(itemCatHomePage, "Home Page & Startup", "Startup & Bookmarks", SettingsCategory.HOME_PAGE)
+ openMainCategory(itemCatHomePage, R.string.settings_home_page_and_startup, R.string.settings_new_tab_grid_custom, SettingsCategory.HOME_PAGE)
}
bindCategoryItem(itemCatUserAgent) {
- openMainCategory(itemCatUserAgent, "User Agent & Identity", "Desktop / Mobile Mode", SettingsCategory.USER_AGENT)
+ openMainCategory(itemCatUserAgent, R.string.settings_user_agent_and_identity, R.string.settings_desktop_mode_mobile_browser, SettingsCategory.USER_AGENT)
}
bindCategoryItem(itemCatWebEngine) {
- openMainCategory(itemCatWebEngine, "Web Engine & Rendering", "Browser Core", SettingsCategory.WEB_ENGINE)
+ openMainCategory(itemCatWebEngine, R.string.settings_web_engine_and_rendering, R.string.settings_system_webview_gpu_acceleration, SettingsCategory.WEB_ENGINE)
}
bindCategoryItem(itemCatAdBlock) {
- openMainCategory(itemCatAdBlock, "Ad & Tracker Blocker", "Filters & Rules", SettingsCategory.AD_BLOCKER)
+ openMainCategory(itemCatAdBlock, R.string.settings_ad_and_tracker_blocker, R.string.settings_easylist_filter_rules_auto, SettingsCategory.AD_BLOCKER)
}
bindCategoryItem(itemCatAppLock) {
- openMainCategory(itemCatAppLock, "App Lock & PIN Security", "Master Passcode", SettingsCategory.APP_LOCK)
+ openMainCategory(itemCatAppLock, R.string.settings_app_lock_and_pin, R.string.settings_passcode_protection_incognito_lock, SettingsCategory.APP_LOCK)
}
bindCategoryItem(itemCatCacheStorage) {
- openMainCategory(itemCatCacheStorage, "Cache & Storage", "Data & History", SettingsCategory.CACHE_STORAGE)
+ openMainCategory(itemCatCacheStorage, R.string.settings_cache_and_storage, R.string.settings_clear_browsing_history_cookies, SettingsCategory.CACHE_STORAGE)
}
bindCategoryItem(itemCatQuickTools) {
- openMainCategory(itemCatQuickTools, "Quick Tools & Utilities", "Toolbox & Actions", SettingsCategory.QUICK_TOOLS)
+ openMainCategory(itemCatQuickTools, R.string.settings_quick_tools_and_utilities, R.string.settings_keep_screen_on_downloads, SettingsCategory.QUICK_TOOLS)
}
bindCategoryItem(itemCatRemoteNav) {
- openMainCategory(itemCatRemoteNav, "Remote & Joystick", "Navigation Controls", SettingsCategory.REMOTE_NAV)
+ openMainCategory(itemCatRemoteNav, R.string.settings_remote_and_joystick, R.string.settings_d_pad_navigation_analog, SettingsCategory.REMOTE_NAV)
}
bindCategoryItem(itemCatCursorPhysics) {
- openMainCategory(itemCatCursorPhysics, "Virtual Cursor & Physics", "Pointer Controls", SettingsCategory.CURSOR_PHYSICS)
+ openMainCategory(itemCatCursorPhysics, R.string.settings_virtual_cursor_and_physics, R.string.settings_speed_acceleration_cursor_size, SettingsCategory.CURSOR_PHYSICS)
}
bindCategoryItem(itemCatKeyboardMouse) {
- openMainCategory(itemCatKeyboardMouse, "Keyboard & Mouse", "Hardware Devices", SettingsCategory.KEYBOARD_MOUSE)
+ openMainCategory(itemCatKeyboardMouse, R.string.settings_keyboard_and_mouse, R.string.settings_virtual_keyboard_pointer_customisation, SettingsCategory.KEYBOARD_MOUSE)
}
bindCategoryItem(itemCatShortcuts) {
openShortcuts(itemCatShortcuts)
@@ -261,13 +319,13 @@ class SettingsDialog(private val context: Context, val model: SettingsModel) :
private fun openMainCategory(
clickedItem: View,
- title: String,
- badge: String,
+ @androidx.annotation.StringRes titleRes: Int,
+ @androidx.annotation.StringRes badgeRes: Int,
category: SettingsCategory
) {
lastFocusedCategoryItem = clickedItem
- tvSettingsDetailTitle.text = title
- tvSettingsDetailBadge.text = badge
+ tvSettingsDetailTitle.setText(titleRes)
+ tvSettingsDetailBadge.setText(badgeRes)
val mv = mainView ?: return
flTabsContent.removeAllViews()
@@ -280,7 +338,7 @@ class SettingsDialog(private val context: Context, val model: SettingsModel) :
private fun openShortcuts(clickedItem: View) {
lastFocusedCategoryItem = clickedItem
tvSettingsDetailTitle.text = context.getString(R.string.tab_shortcuts)
- tvSettingsDetailBadge.text = "Navigation & Keys"
+ tvSettingsDetailBadge.setText(R.string.settings_navigation_and_keys)
val sv = shortcutsView ?: return
flTabsContent.removeAllViews()
@@ -291,8 +349,8 @@ class SettingsDialog(private val context: Context, val model: SettingsModel) :
private fun openAbout(clickedItem: View) {
lastFocusedCategoryItem = clickedItem
- tvSettingsDetailTitle.text = "About & Version"
- tvSettingsDetailBadge.text = "Build Information"
+ tvSettingsDetailTitle.setText(R.string.settings_about_and_version)
+ tvSettingsDetailBadge.setText(R.string.settings_gothwad_browser_build_details)
val vv = versionView ?: return
flTabsContent.removeAllViews()
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..21a5c89a 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,
@@ -211,7 +232,7 @@ object SettingsEngineSection {
config.adBlockListURL.value = url
}
adblockModel.loadAdBlockList(forceReload = true)
- Toast.makeText(context, "Updating ad blocker filters...", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, context.getString(R.string.settings_updating_ad_blocker_filters), Toast.LENGTH_SHORT).show()
}
}
diff --git a/app/src/main/java/com/gothwad/browser/settings/SettingsGeneralSection.kt b/app/src/main/java/com/gothwad/browser/settings/SettingsGeneralSection.kt
index 88127dbf..344fb0e9 100644
--- a/app/src/main/java/com/gothwad/browser/settings/SettingsGeneralSection.kt
+++ b/app/src/main/java/com/gothwad/browser/settings/SettingsGeneralSection.kt
@@ -7,6 +7,7 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.Toast
+import androidx.core.content.ContextCompat
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.lifecycleScope
@@ -102,7 +103,7 @@ object SettingsGeneralSection {
onSuccess = {
com.gothwad.browser.singleton.AppLockManager.setLockEnabled(context, false)
syncSwitchState()
- Toast.makeText(context, "PIN Lock Disabled", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, context.getString(R.string.settings_pin_lock_disabled), Toast.LENGTH_SHORT).show()
},
onCancel = {
syncSwitchState()
@@ -149,17 +150,17 @@ object SettingsGeneralSection {
val hasPin = com.gothwad.browser.singleton.AppLockManager.hasPinSet(context)
if (isEnabled) {
vb.tvAppLockStatus.text = "PIN Lock Active (Protected)"
- vb.tvAppLockStatus.setTextColor(0xFF38BDF8.toInt())
+ vb.tvAppLockStatus.setTextColor(ContextCompat.getColor(context, R.color.app_accent))
vb.btnChangePin.visibility = View.VISIBLE
vb.btnChangePin.text = "Change PIN"
} else if (hasPin) {
vb.tvAppLockStatus.text = "PIN Lock Disabled (PIN configured)"
- vb.tvAppLockStatus.setTextColor(0xFF94A3B8.toInt())
+ vb.tvAppLockStatus.setTextColor(ContextCompat.getColor(context, R.color.app_on_surface_muted))
vb.btnChangePin.visibility = View.VISIBLE
vb.btnChangePin.text = "Change PIN"
} else {
vb.tvAppLockStatus.text = "No PIN set (Set 4-digit PIN to enable)"
- vb.tvAppLockStatus.setTextColor(0xFF94A3B8.toInt())
+ vb.tvAppLockStatus.setTextColor(ContextCompat.getColor(context, R.color.app_on_surface_muted))
vb.btnChangePin.visibility = View.GONE
}
}
@@ -204,40 +205,45 @@ object SettingsGeneralSection {
}
config.screenOrientation = nextOrientation
mainAct?.applyScreenOrientation()
- val label = when (nextOrientation) {
- Config.ORIENTATION_PORTRAIT -> "Screen Orientation: Portrait"
- Config.ORIENTATION_AUTO -> "Screen Orientation: Auto Rotate"
- else -> "Screen Orientation: Landscape"
- }
+ val label = context.getString(
+ when (nextOrientation) {
+ Config.ORIENTATION_PORTRAIT -> R.string.settings_orientation_portrait
+ Config.ORIENTATION_AUTO -> R.string.settings_orientation_auto
+ else -> R.string.settings_orientation_landscape
+ }
+ )
Toast.makeText(context, label, Toast.LENGTH_SHORT).show()
}
- vb.btnQuickZoomIn.setOnClickListener {
- mainAct?.zoomWebIn()
+ // One refresh path for the three zoom buttons (they used to duplicate the same five lines).
+ fun refreshZoomUi(): String {
val isDesk = config.isDesktopMode()
val zoom = config.getEffectiveZoom(isDesk)
- vb.sbWebPageZoom.progress = (zoom - Config.WEB_PAGE_ZOOM_PERCENT_MIN).coerceIn(0, Config.WEB_PAGE_ZOOM_PERCENT_MAX - Config.WEB_PAGE_ZOOM_PERCENT_MIN)
- val modeTitle = if (isDesk) "Desktop" else "Mobile"
- vb.tvWebPageZoomValue.text = if (zoom == 100) "$modeTitle: 100% (Default)" else "$modeTitle: $zoom%"
- Toast.makeText(context, "$modeTitle: $zoom%", Toast.LENGTH_SHORT).show()
+ vb.sbWebPageZoom.progress =
+ (zoom - Config.WEB_PAGE_ZOOM_PERCENT_MIN)
+ .coerceIn(0, Config.WEB_PAGE_ZOOM_PERCENT_MAX - Config.WEB_PAGE_ZOOM_PERCENT_MIN)
+ val modeTitle = context.getString(if (isDesk) R.string.mode_desktop else R.string.mode_mobile)
+ vb.tvWebPageZoomValue.text = if (zoom == Config.WEB_PAGE_ZOOM_PERCENT_DEFAULT) {
+ context.getString(R.string.settings_zoom_value_default, modeTitle)
+ } else {
+ context.getString(R.string.settings_zoom_value, modeTitle, zoom)
+ }
+ return context.getString(R.string.settings_zoom_value, modeTitle, zoom)
+ }
+
+ vb.btnQuickZoomIn.setOnClickListener {
+ mainAct?.zoomWebIn()
+ Toast.makeText(context, refreshZoomUi(), Toast.LENGTH_SHORT).show()
}
vb.btnQuickZoomOut.setOnClickListener {
mainAct?.zoomWebOut()
- val isDesk = config.isDesktopMode()
- val zoom = config.getEffectiveZoom(isDesk)
- vb.sbWebPageZoom.progress = (zoom - Config.WEB_PAGE_ZOOM_PERCENT_MIN).coerceIn(0, Config.WEB_PAGE_ZOOM_PERCENT_MAX - Config.WEB_PAGE_ZOOM_PERCENT_MIN)
- val modeTitle = if (isDesk) "Desktop" else "Mobile"
- vb.tvWebPageZoomValue.text = if (zoom == 100) "$modeTitle: 100% (Default)" else "$modeTitle: $zoom%"
- Toast.makeText(context, "$modeTitle: $zoom%", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, refreshZoomUi(), Toast.LENGTH_SHORT).show()
}
vb.btnQuickZoomReset.setOnClickListener {
- val isDesk = config.isDesktopMode()
- mainAct?.applyWebPageZoom(100, isDesk)
- vb.sbWebPageZoom.progress = 100 - Config.WEB_PAGE_ZOOM_PERCENT_MIN
- val modeTitle = if (isDesk) "Desktop" else "Mobile"
- vb.tvWebPageZoomValue.text = "$modeTitle: 100% (Default)"
+ mainAct?.applyWebPageZoom(Config.WEB_PAGE_ZOOM_PERCENT_DEFAULT, config.isDesktopMode())
+ vb.sbWebPageZoom.progress = Config.WEB_PAGE_ZOOM_PERCENT_DEFAULT - Config.WEB_PAGE_ZOOM_PERCENT_MIN
Toast.makeText(context, R.string.quick_zoom_reset, Toast.LENGTH_SHORT).show()
}
}
@@ -252,10 +258,10 @@ object SettingsGeneralSection {
val isDefault = com.gothwad.browser.utils.DefaultBrowserHelper.isDefaultBrowser(context)
if (isDefault) {
vb.tvDefaultBrowserStatus.text = "Status: Currently Default Browser (Active)"
- vb.tvDefaultBrowserStatus.setTextColor(0xFF38BDF8.toInt())
+ vb.tvDefaultBrowserStatus.setTextColor(ContextCompat.getColor(context, R.color.app_accent))
} else {
vb.tvDefaultBrowserStatus.text = "Status: Not Default Browser (Click to Set)"
- vb.tvDefaultBrowserStatus.setTextColor(0xFF94A3B8.toInt())
+ vb.tvDefaultBrowserStatus.setTextColor(ContextCompat.getColor(context, R.color.app_on_surface_muted))
}
}
@@ -296,13 +302,13 @@ object SettingsGeneralSection {
vb.btnClearHistorySettings.setOnClickListener {
android.app.AlertDialog.Builder(context)
- .setTitle("Clear Browsing History")
- .setMessage("Are you sure you want to delete all saved browsing history? This action cannot be undone.")
+ .setTitle(R.string.settings_clear_browsing_history)
+ .setMessage(R.string.settings_delete_history_confirm)
.setPositiveButton("Clear History") { _, _ ->
(activity as? androidx.appcompat.app.AppCompatActivity)?.lifecycleScope?.launch(Dispatchers.IO) {
com.gothwad.browser.singleton.AppDatabase.db.historyDao().deleteAll()
withContext(Dispatchers.Main) {
- Toast.makeText(context, "Browsing history cleared", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, context.getString(R.string.menu_browsing_history_cleared), Toast.LENGTH_SHORT).show()
}
}
}
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..cb84c6f7 100644
--- a/app/src/main/java/com/gothwad/browser/settings/SettingsRemoteSection.kt
+++ b/app/src/main/java/com/gothwad/browser/settings/SettingsRemoteSection.kt
@@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat
import com.gothwad.browser.Config
import com.gothwad.browser.R
import com.gothwad.browser.activity.main.MainActivity
+import com.gothwad.browser.activity.main.applyInputMode
import com.gothwad.browser.databinding.ViewSettingsMainBinding
import com.gothwad.browser.utils.HardwareInputManager
import com.gothwad.browser.utils.HardwareMousePointerManager
@@ -133,20 +134,36 @@ object SettingsRemoteSection {
val devIdStr = dev.id.toString()
val isBlocked = config.isInputDeviceBlocked(devIdStr) || config.isInputDeviceBlocked(dev.name)
- val typeIcon = when {
- dev.isMouse && dev.isKeyboard -> "⌨️🖱️"
- dev.isMouse -> "🖱️"
- dev.isKeyboard -> "⌨️"
- dev.isGamepad -> "🎮"
- else -> "🔌"
+ // Device kind as an icon + a plain text label: the emoji glyphs this used to print were
+ // unreadable on some TV fonts and carried no information a screen reader could use.
+ val typeIconRes = when {
+ dev.isMouse || dev.isKeyboard -> R.drawable.ic_settings_keyboard_mouse
+ dev.isGamepad -> R.drawable.gamepad_circle_up_24px
+ else -> R.drawable.ic_usb
}
-
- val iconTv = TextView(context).apply {
- text = typeIcon
- textSize = 20f
- setPadding(0, 0, Utils.D2P(context, 10f).toInt(), 0)
+ val typeLabel = context.getString(
+ when {
+ dev.isMouse && dev.isKeyboard -> R.string.settings_device_type_kb_mouse
+ dev.isMouse -> R.string.settings_device_type_mouse
+ dev.isKeyboard -> R.string.settings_device_type_keyboard
+ dev.isGamepad -> R.string.settings_device_type_gamepad
+ else -> R.string.settings_device_type_other
+ }
+ )
+
+ val iconView = android.widget.ImageView(context).apply {
+ setImageResource(typeIconRes)
+ setImageTintList(
+ android.content.res.ColorStateList.valueOf(
+ ContextCompat.getColor(context, R.color.app_on_surface_muted)
+ )
+ )
+ layoutParams = LinearLayout.LayoutParams(
+ Utils.D2P(context, 22f).toInt(), Utils.D2P(context, 22f).toInt()
+ ).apply { rightMargin = Utils.D2P(context, 10f).toInt() }
+ contentDescription = typeLabel
}
- itemLayout.addView(iconTv)
+ itemLayout.addView(iconView)
val textColumn = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
@@ -155,17 +172,20 @@ 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
}
textColumn.addView(nameTv)
val descTv = TextView(context).apply {
- val stateText = if (isBlocked) "🚫 Blocked (Input Ignored)" else "✅ Allowed (Active & Optimized)"
- val stateColor = if (isBlocked) "#F87171" else "#4ADE80"
- text = "ID: ${dev.id} • $stateText"
- setTextColor(Color.parseColor(stateColor))
+ text = context.getString(R.string.settings_device_state, dev.id, typeLabel)
+ setTextColor(
+ ContextCompat.getColor(
+ context,
+ if (isBlocked) R.color.app_error else R.color.app_success
+ )
+ )
textSize = 12f
setPadding(0, Utils.D2P(context, 2f).toInt(), 0, 0)
}
@@ -176,8 +196,8 @@ object SettingsRemoteSection {
isChecked = !isBlocked // Checked means Allowed/Active
isFocusable = true
isClickable = true
- text = if (isBlocked) "Block" else "Allow"
- setTextColor(Color.parseColor("#94A3B8"))
+ text = context.getString(if (isBlocked) R.string.settings_block else R.string.allow)
+ setTextColor(ContextCompat.getColor(context, R.color.app_on_surface_muted))
textSize = 12f
}
@@ -186,15 +206,13 @@ object SettingsRemoteSection {
config.setInputDeviceBlocked(devIdStr, newBlocked)
config.setInputDeviceBlocked(dev.name, newBlocked)
if (newBlocked) {
- descTv.text = "ID: ${dev.id} • 🚫 Blocked (Input Ignored)"
- descTv.setTextColor(Color.parseColor("#F87171"))
- blockSwitch.text = "Block"
- Toast.makeText(context, "${dev.name} is now BLOCKED", Toast.LENGTH_SHORT).show()
+ descTv.setTextColor(ContextCompat.getColor(context, R.color.app_error))
+ blockSwitch.setText(R.string.settings_block)
+ Toast.makeText(context, context.getString(R.string.settings_device_blocked, dev.name), Toast.LENGTH_SHORT).show()
} else {
- descTv.text = "ID: ${dev.id} • ✅ Allowed (Active & Optimized)"
- descTv.setTextColor(Color.parseColor("#4ADE80"))
- blockSwitch.text = "Allow"
- Toast.makeText(context, "${dev.name} is now ALLOWED", Toast.LENGTH_SHORT).show()
+ descTv.setTextColor(ContextCompat.getColor(context, R.color.app_success))
+ blockSwitch.setText(R.string.allow)
+ Toast.makeText(context, context.getString(R.string.settings_device_allowed, dev.name), Toast.LENGTH_SHORT).show()
}
}
@@ -211,9 +229,13 @@ object SettingsRemoteSection {
vb.llConnectedDevicesContainer.removeAllViews()
if (nonVirtual.isEmpty()) {
- vb.tvConnectedHardwareStatus.text = "⚠️ No external physical keyboard or mouse currently detected.\n(Using standard TV Remote / D-Pad navigation)"
+ vb.tvConnectedHardwareStatus.setText(R.string.settings_no_external_devices)
} else {
- vb.tvConnectedHardwareStatus.text = "Found ${nonVirtual.size} connected input device(s). You can toggle Allow/Block for each device below:"
+ vb.tvConnectedHardwareStatus.text = context.resources.getQuantityString(
+ R.plurals.settings_connected_devices,
+ nonVirtual.size,
+ nonVirtual.size
+ )
for (dev in nonVirtual) {
val itemView = renderDeviceItem(dev)
vb.llConnectedDevicesContainer.addView(itemView)
@@ -223,14 +245,26 @@ object SettingsRemoteSection {
hwInput.addListener(object : HardwareInputManager.InputDevicesListener {
override fun onInputDevicesChanged(devices: List) {
- vb.root.post { updateDeviceList(devices) }
+ vb.root.post {
+ updateDeviceList(devices)
+ // In AUTO mode a mouse being plugged in / removed *is* a mode change.
+ mainAct?.applyInputMode()
+ }
}
})
vb.btnRefreshDevices.setOnClickListener {
val devs = hwInput.getConnectedDevices()
updateDeviceList(devs)
- Toast.makeText(context, "Scanned: ${devs.count { !it.isVirtual }} device(s) found", Toast.LENGTH_SHORT).show()
+ Toast.makeText(
+ context,
+ context.resources.getQuantityString(
+ R.plurals.settings_devices_found,
+ devs.count { !it.isVirtual },
+ devs.count { !it.isVirtual }
+ ),
+ Toast.LENGTH_SHORT
+ ).show()
}
updateDeviceList(hwInput.getConnectedDevices())
diff --git a/app/src/main/java/com/gothwad/browser/singleton/AppDatabase.kt b/app/src/main/java/com/gothwad/browser/singleton/AppDatabase.kt
index 798fc8c7..85fb00cb 100644
--- a/app/src/main/java/com/gothwad/browser/singleton/AppDatabase.kt
+++ b/app/src/main/java/com/gothwad/browser/singleton/AppDatabase.kt
@@ -16,7 +16,7 @@ import com.gothwad.browser.model.util.Converters
HistoryItem::class, WebTabState::class,
HostConfig::class
],
- version = 21, exportSchema = false)
+ version = 22, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao
@@ -165,6 +165,13 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ private val MIGRATION_21_22 = object : Migration(21, 22) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Per-tab desktop-mode override (null = follow the global default).
+ db.execSQL("ALTER TABLE tabs ADD COLUMN desktop_mode INTEGER;")
+ }
+ }
+
private val MIGRATION_20_21 = object : Migration(20, 21) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DROP TABLE IF EXISTS `notes`")
@@ -217,7 +224,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_1_2, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14,
MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19,
- MIGRATION_19_20, MIGRATION_20_21
+ MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22
)
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
diff --git a/app/src/main/java/com/gothwad/browser/singleton/AppLockManager.kt b/app/src/main/java/com/gothwad/browser/singleton/AppLockManager.kt
index fab699c6..ca365709 100644
--- a/app/src/main/java/com/gothwad/browser/singleton/AppLockManager.kt
+++ b/app/src/main/java/com/gothwad/browser/singleton/AppLockManager.kt
@@ -2,21 +2,82 @@ package com.gothwad.browser.singleton
import android.content.Context
import android.content.SharedPreferences
+import android.util.Base64
+import android.util.Log
import com.gothwad.browser.BrowserApp
import java.io.File
+import java.security.MessageDigest
+import java.security.SecureRandom
+import javax.crypto.SecretKeyFactory
+import javax.crypto.spec.PBEKeySpec
+/**
+ * App Lock (PIN) storage.
+ *
+ * History of this file (do not regress it):
+ * - the PIN used to be stored as **plaintext**, twice: in `SharedPreferences` under a key that was
+ * optimistically called `app_lock_pin_hash` and again in `files/app_lock_pin.dat`. Anyone with
+ * `adb run-as`/a backup of the app data, or any app that could read the backup, got the PIN.
+ * - the "session unlocked" marker was a file containing a timestamp that was **never checked**,
+ * so the very first successful unlock unlocked the lock for all future launches, permanently -
+ * i.e. the feature was decorative.
+ * - there was no limit on guesses: a 4-digit PIN is 10 000 combinations, trivially walkable with a
+ * TV remote or `adb shell input`.
+ *
+ * Now: PBKDF2-HMAC-SHA256 with a per-install random salt, constant-time comparison, a failed-attempt
+ * lockout with exponential back-off, and a session grant that *expires*.
+ *
+ * The public API is unchanged so `AppLockActivity` / `TvPinDialog` / `MainActivity` keep working;
+ * legacy plaintext records are migrated transparently on the next successful verification.
+ */
object AppLockManager {
+ private const val TAG = "AppLockManager"
+
private const val PREFS_NAME = "tv_browser_app_lock_prefs"
private const val KEY_LOCK_ENABLED = "app_lock_enabled"
- private const val KEY_PIN_HASH = "app_lock_pin"
+ private const val KEY_PIN_RECORD = "app_lock_pin"
+ private const val KEY_FAILED_ATTEMPTS = "app_lock_failed_attempts"
+ private const val KEY_LOCKOUT_UNTIL = "app_lock_lockout_until"
+
+ private const val RECORD_SCHEME = "pbkdf2"
+ private const val PBKDF2_ITERATIONS = 25000
+ private const val PBKDF2_KEY_BITS = 256
+ private const val SALT_BYTES = 16
+ /** Files kept only to migrate pre-hash installs, then deleted. */
private const val FILE_PIN = "app_lock_pin.dat"
private const val FILE_ENABLED = "app_lock_enabled.flag"
private const val FILE_SESSION_TOKEN = "app_lock_session.token"
+ /**
+ * How long an unlocked session stays valid across processes/relaunches. Long enough that
+ * jumping to the incognito process or restoring from Recents does not ask twice, short enough
+ * that "locked again after you left the app" is actually true.
+ */
+ private const val SESSION_GRACE_MS = 60_000L
+
+ private const val LOCKOUT_BASE_MS = 15_000L
+ private const val LOCKOUT_MAX_MS = 15L * 60L * 1000L
+ private const val MAX_ATTEMPTS_BEFORE_LOCKOUT = 5
+
@Volatile
private var isSessionUnlocked = false
+ /**
+ * The lock state is read on every `onResume`. SharedPreferences are in-memory, but the
+ * pre-prefs installs also left flag files, and `File.exists()` on the UI thread is exactly the
+ * kind of thing AGENTS.md rule 6 forbids - so the answer is cached and only invalidated by the
+ * methods that change it.
+ */
+ @Volatile
+ private var lockEnabledCache: Boolean? = null
+
+ @Volatile
+ private var legacyPin: String? = null
+
+ @Volatile
+ private var legacyPinChecked = false
+
private fun getFilesDir(context: Context? = null): File? {
return try {
context?.filesDir ?: BrowserApp.instance.filesDir
@@ -25,73 +86,80 @@ object AppLockManager {
}
}
- private fun getPrefs(context: Context): SharedPreferences {
- return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private fun getPrefs(context: Context): SharedPreferences =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ // ---------------------------------------------------------------- PIN storage
+
+ private fun derive(pin: String, saltB64: String): String {
+ val salt = Base64.decode(saltB64, Base64.NO_WRAP)
+ val spec = PBEKeySpec(pin.toCharArray(), salt, PBKDF2_ITERATIONS, PBKDF2_KEY_BITS)
+ val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
+ return Base64.encodeToString(factory.generateSecret(spec).encoded, Base64.NO_WRAP)
}
+ private fun isLegacyPlaintext(record: String): Boolean =
+ record.length == 4 && record.all { it.isDigit() }
+
+ /** @return true when a usable PIN exists (hashed, or a legacy plaintext record). */
fun hasPinSet(context: Context): Boolean {
- val pin = getPin(context)
- return !pin.isNullOrEmpty() && pin.length == 4 && pin.all { it.isDigit() }
+ val record = getPrefs(context).getString(KEY_PIN_RECORD, null)
+ if (!record.isNullOrEmpty()) {
+ if (isLegacyPlaintext(record)) return true
+ val parts = record.split(':')
+ return parts.size == 4 && parts[0] == RECORD_SCHEME && parts[3].isNotEmpty()
+ }
+ // Migration source only: a pre-hash install still has the raw file.
+ return legacyPinOf(context) != null
}
- fun isLockEnabled(context: Context): Boolean {
- if (!hasPinSet(context)) return false
- val enabledInPrefs = getPrefs(context).getBoolean(KEY_LOCK_ENABLED, false)
- if (enabledInPrefs) return true
- val flagFile = getFilesDir(context)?.let { File(it, FILE_ENABLED) }
- return flagFile?.exists() == true
+ private fun legacyPinOf(context: Context): String? {
+ if (legacyPinChecked) return legacyPin
+ val pin = readLegacyPinFile(context)
+ legacyPin = pin
+ legacyPinChecked = true
+ return pin
}
- fun setLockEnabled(context: Context, enabled: Boolean) {
- if (enabled && !hasPinSet(context)) {
- return
+ private fun readLegacyPinFile(context: Context): String? {
+ val pinFile = getFilesDir(context)?.let { File(it, FILE_PIN) } ?: return null
+ if (!pinFile.exists()) return null
+ return try {
+ pinFile.readText().trim().takeIf { it.length == 4 && it.all { c -> c.isDigit() } }
+ } catch (_: Throwable) {
+ null
}
- getPrefs(context).edit().putBoolean(KEY_LOCK_ENABLED, enabled).commit()
- val flagFile = getFilesDir(context)?.let { File(it, FILE_ENABLED) }
- try {
- if (enabled) {
- flagFile?.writeText("true")
- } else {
- flagFile?.delete()
- setSessionUnlocked(context, true)
- }
- } catch (_: Throwable) {}
}
- fun getPin(context: Context): String? {
- val prefsPin = getPrefs(context).getString(KEY_PIN_HASH, null)
- if (!prefsPin.isNullOrEmpty()) return prefsPin
- val pinFile = getFilesDir(context)?.let { File(it, FILE_PIN) }
- if (pinFile?.exists() == true) {
- val filePin = try { pinFile.readText().trim() } catch (_: Throwable) { null }
- if (!filePin.isNullOrEmpty()) {
- getPrefs(context).edit().putString(KEY_PIN_HASH, filePin).commit()
- return filePin
- }
- }
- return null
+ private fun writeRecord(context: Context, pin: String) {
+ val salt = ByteArray(SALT_BYTES).also { SecureRandom().nextBytes(it) }
+ val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
+ val hash = derive(pin, saltB64)
+ getPrefs(context).edit()
+ .putString(KEY_PIN_RECORD, "$RECORD_SCHEME:$PBKDF2_ITERATIONS:$saltB64:$hash")
+ .apply()
}
fun setPin(context: Context, pin: String) {
- if (pin.length == 4 && pin.all { it.isDigit() }) {
- getPrefs(context).edit().putString(KEY_PIN_HASH, pin).commit()
- val pinFile = getFilesDir(context)?.let { File(it, FILE_PIN) }
- try {
- pinFile?.writeText(pin)
- } catch (_: Throwable) {}
- }
- }
-
- fun verifyPin(context: Context, inputPin: String): Boolean {
- val currentPin = getPin(context) ?: return false
- return inputPin == currentPin
+ if (pin.length != 4 || !pin.all { it.isDigit() }) return
+ writeRecord(context, pin)
+ // Remove the plaintext leftovers from older versions, including the one this class used to write.
+ try {
+ getFilesDir(context)?.let { File(it, FILE_PIN).delete() }
+ } catch (_: Throwable) {}
+ resetFailures(context)
}
fun clearPinAndDisableLock(context: Context) {
+ lockEnabledCache = false
+ legacyPin = null
+ legacyPinChecked = false
getPrefs(context).edit()
- .remove(KEY_PIN_HASH)
+ .remove(KEY_PIN_RECORD)
.putBoolean(KEY_LOCK_ENABLED, false)
- .commit()
+ .remove(KEY_FAILED_ATTEMPTS)
+ .remove(KEY_LOCKOUT_UNTIL)
+ .apply()
try {
getFilesDir(context)?.let { dir ->
File(dir, FILE_PIN).delete()
@@ -101,42 +169,171 @@ object AppLockManager {
setSessionUnlocked(context, true)
}
- fun isSessionUnlocked(context: Context? = null): Boolean {
- if (isSessionUnlocked) return true
- val tokenFile = getFilesDir(context)?.let { File(it, FILE_SESSION_TOKEN) }
- if (tokenFile?.exists() == true) {
- isSessionUnlocked = true
- return true
+ // ---------------------------------------------------------------- verification + lockout
+
+ fun isLockedOut(context: Context): Boolean = lockoutRemainingMs(context) > 0
+
+ fun lockoutRemainingMs(context: Context): Long {
+ val until = getPrefs(context).getLong(KEY_LOCKOUT_UNTIL, 0L)
+ return (until - System.currentTimeMillis()).coerceAtLeast(0L)
+ }
+
+ private fun resetFailures(context: Context) {
+ getPrefs(context).edit()
+ .remove(KEY_FAILED_ATTEMPTS)
+ .remove(KEY_LOCKOUT_UNTIL)
+ .apply()
+ }
+
+ private fun registerFailure(context: Context): Long {
+ val prefs = getPrefs(context)
+ val attempts = prefs.getInt(KEY_FAILED_ATTEMPTS, 0) + 1
+ val editor = prefs.edit().putInt(KEY_FAILED_ATTEMPTS, attempts)
+ var remaining = 0L
+ if (attempts >= MAX_ATTEMPTS_BEFORE_LOCKOUT) {
+ val exponent = (attempts - MAX_ATTEMPTS_BEFORE_LOCKOUT).coerceAtMost(5)
+ val wait = (LOCKOUT_BASE_MS shl exponent).coerceAtMost(LOCKOUT_MAX_MS)
+ editor.putLong(KEY_LOCKOUT_UNTIL, System.currentTimeMillis() + wait)
+ remaining = wait
}
- return false
+ editor.apply()
+ return remaining
}
- fun setSessionUnlocked(context: Context, unlocked: Boolean) {
- isSessionUnlocked = unlocked
- val tokenFile = getFilesDir(context)?.let { File(it, FILE_SESSION_TOKEN) }
+ /**
+ * @param lockoutMs when the PIN is now rate-limited, how long the caller must wait before the
+ * next attempt (0 = allow immediately).
+ */
+ class VerifyResult(val success: Boolean, val lockoutMs: Long, val legacyMigrated: Boolean)
+
+ fun verifyPinDetailed(context: Context, inputPin: String): VerifyResult {
+ val remaining = lockoutRemainingMs(context)
+ if (remaining > 0) return VerifyResult(false, remaining, false)
+
+ val prefs = getPrefs(context)
+ var record = prefs.getString(KEY_PIN_RECORD, null)
+ if (record.isNullOrEmpty()) {
+ val legacyFile = legacyPinOf(context)
+ if (legacyFile == null) return VerifyResult(false, 0, false)
+ if (constantTimeEquals(legacyFile, inputPin)) {
+ migrateToHash(context, legacyFile)
+ // The plaintext file is gone now; never look at it again.
+ legacyPin = null
+ legacyPinChecked = false
+ return VerifyResult(true, 0, true)
+ }
+ registerFailure(context)
+ return VerifyResult(false, 0, false)
+ }
+
+ if (isLegacyPlaintext(record)) {
+ // Very old installs stored the PIN itself. Compare, then upgrade in place.
+ if (constantTimeEquals(record, inputPin)) {
+ migrateToHash(context, inputPin)
+ return VerifyResult(true, 0, true)
+ }
+ val wait = registerFailure(context)
+ return VerifyResult(false, wait, false)
+ }
+
+ val parts = record.split(':')
+ if (parts.size != 4 || parts[0] != RECORD_SCHEME) {
+ // Unreadable record (should not happen): fail closed but let the user set a new PIN.
+ Log.w(TAG, "Corrupt app-lock record, refusing unlock")
+ return VerifyResult(false, 0, false)
+ }
+ val ok = try {
+ constantTimeEquals(derive(inputPin, parts[2]), parts[3])
+ } catch (e: Throwable) {
+ Log.w(TAG, "PBKDF2 unavailable on this device", e)
+ false
+ }
+ if (ok) {
+ resetFailures(context)
+ return VerifyResult(true, 0, false)
+ }
+ return VerifyResult(false, registerFailure(context), false)
+ }
+
+ private fun migrateToHash(context: Context, pin: String) {
try {
- if (unlocked) {
- tokenFile?.writeText(System.currentTimeMillis().toString())
+ writeRecord(context, pin)
+ getFilesDir(context)?.let { File(it, FILE_PIN).delete() }
+ resetFailures(context)
+ } catch (e: Throwable) {
+ Log.w(TAG, "Failed to migrate legacy PIN storage", e)
+ }
+ }
+
+ private fun constantTimeEquals(a: String, b: String): Boolean =
+ MessageDigest.isEqual(a.toByteArray(), b.toByteArray())
+
+ /** Back-compatible boolean form used by the dialogs. */
+ fun verifyPin(context: Context, inputPin: String): Boolean =
+ verifyPinDetailed(context, inputPin).success
+
+ // ---------------------------------------------------------------- enable / session
+
+ fun isLockEnabled(context: Context): Boolean {
+ if (!hasPinSet(context)) return false
+ lockEnabledCache?.let { return it }
+ val enabled = if (getPrefs(context).getBoolean(KEY_LOCK_ENABLED, false)) {
+ true
+ } else {
+ val flagFile = getFilesDir(context)?.let { File(it, FILE_ENABLED) }
+ flagFile?.exists() == true
+ }
+ lockEnabledCache = enabled
+ return enabled
+ }
+
+ fun setLockEnabled(context: Context, enabled: Boolean) {
+ if (enabled && !hasPinSet(context)) return
+ lockEnabledCache = enabled
+ getPrefs(context).edit().putBoolean(KEY_LOCK_ENABLED, enabled).apply()
+ val flagFile = getFilesDir(context)?.let { File(it, FILE_ENABLED) }
+ try {
+ if (enabled) {
+ flagFile?.writeText("true")
} else {
- tokenFile?.delete()
+ flagFile?.delete()
+ setSessionUnlocked(context, true)
}
} catch (_: Throwable) {}
}
- fun setSessionUnlocked(unlocked: Boolean) {
+ fun isSessionUnlocked(context: Context? = null): Boolean {
+ if (isSessionUnlocked) return true
+ val file = getFilesDir(context)?.let { File(it, FILE_SESSION_TOKEN) } ?: return false
+ if (!file.exists()) return false
+ return try {
+ val expiresAt = file.readText().trim().toLongOrNull() ?: 0L
+ if (System.currentTimeMillis() < expiresAt) {
+ isSessionUnlocked = true
+ true
+ } else {
+ file.delete()
+ false
+ }
+ } catch (_: Throwable) {
+ false
+ }
+ }
+
+ fun setSessionUnlocked(context: Context?, unlocked: Boolean) {
isSessionUnlocked = unlocked
- val tokenFile = getFilesDir()?.let { File(it, FILE_SESSION_TOKEN) }
+ val file = getFilesDir(context)?.let { File(it, FILE_SESSION_TOKEN) } ?: return
try {
if (unlocked) {
- tokenFile?.writeText(System.currentTimeMillis().toString())
+ file.writeText((System.currentTimeMillis() + SESSION_GRACE_MS).toString())
} else {
- tokenFile?.delete()
+ file.delete()
}
} catch (_: Throwable) {}
}
- fun requiresUnlock(context: Context): Boolean {
- return isLockEnabled(context) && !isSessionUnlocked(context)
- }
-}
+ /** Same grace window for the no-context overload used by menu actions. */
+ fun setSessionUnlocked(unlocked: Boolean) = setSessionUnlocked(null as Context?, unlocked)
+ fun requiresUnlock(context: Context): Boolean = isLockEnabled(context) && !isSessionUnlocked(context)
+}
diff --git a/app/src/main/java/com/gothwad/browser/singleton/shortcuts/ShortcutMgr.kt b/app/src/main/java/com/gothwad/browser/singleton/shortcuts/ShortcutMgr.kt
index 85ec1928..1c8b0c5e 100644
--- a/app/src/main/java/com/gothwad/browser/singleton/shortcuts/ShortcutMgr.kt
+++ b/app/src/main/java/com/gothwad/browser/singleton/shortcuts/ShortcutMgr.kt
@@ -186,9 +186,9 @@ class ShortcutMgr private constructor() {
list.add(item)
val sorted = HomeData.sortShortcutsWithGoogleFirst(list)
NativeHomeView.saveUserBookmarks(mainActivity, sorted)
- Toast.makeText(mainActivity, "⭐ Added to Bookmarks: $title", Toast.LENGTH_SHORT).show()
+ Toast.makeText(mainActivity, mainActivity.getString(R.string.shortcut_added_to_bookmarks, title), Toast.LENGTH_SHORT).show()
} else {
- Toast.makeText(mainActivity, "Already in Bookmarks", Toast.LENGTH_SHORT).show()
+ Toast.makeText(mainActivity, mainActivity.getString(R.string.shortcut_already_in_bookmarks), Toast.LENGTH_SHORT).show()
}
}
}
diff --git a/app/src/main/java/com/gothwad/browser/utils/DefaultBrowserHelper.kt b/app/src/main/java/com/gothwad/browser/utils/DefaultBrowserHelper.kt
index c42a8d17..66337ee9 100644
--- a/app/src/main/java/com/gothwad/browser/utils/DefaultBrowserHelper.kt
+++ b/app/src/main/java/com/gothwad/browser/utils/DefaultBrowserHelper.kt
@@ -1,5 +1,6 @@
package com.gothwad.browser.utils
+import com.gothwad.browser.R
import android.app.Activity
import android.app.role.RoleManager
import android.content.Context
@@ -34,7 +35,7 @@ object DefaultBrowserHelper {
val roleManager = context.getSystemService(Context.ROLE_SERVICE) as? RoleManager
if (roleManager != null && roleManager.isRoleAvailable(RoleManager.ROLE_BROWSER)) {
if (roleManager.isRoleHeld(RoleManager.ROLE_BROWSER)) {
- Toast.makeText(context, "Gothwad Browser is already your default browser", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, context.getString(R.string.default_browser_gothwad_browser_is_already), Toast.LENGTH_SHORT).show()
return
}
try {
@@ -59,7 +60,7 @@ object DefaultBrowserHelper {
}
}
context.startActivity(intent)
- Toast.makeText(context, "Select 'Browser app' -> choose 'Gothwad Browser'", Toast.LENGTH_LONG).show()
+ Toast.makeText(context, context.getString(R.string.default_browser_select_browser_app_choose), Toast.LENGTH_LONG).show()
return
} catch (e: Exception) {
// Fallthrough to app details
@@ -75,9 +76,9 @@ object DefaultBrowserHelper {
}
}
context.startActivity(intent)
- Toast.makeText(context, "Set Gothwad Browser as default browser in App Settings", Toast.LENGTH_LONG).show()
+ Toast.makeText(context, context.getString(R.string.default_browser_set_gothwad_browser_as), Toast.LENGTH_LONG).show()
} catch (e: Exception) {
- Toast.makeText(context, "Could not open system settings", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, context.getString(R.string.default_browser_could_not_open_system), Toast.LENGTH_SHORT).show()
}
}
}
diff --git a/app/src/main/java/com/gothwad/browser/utils/ListDiff.kt b/app/src/main/java/com/gothwad/browser/utils/ListDiff.kt
new file mode 100644
index 00000000..f7f22106
--- /dev/null
+++ b/app/src/main/java/com/gothwad/browser/utils/ListDiff.kt
@@ -0,0 +1,54 @@
+package com.gothwad.browser.utils
+
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * Small DiffUtil helper so the RecyclerView adapters in this app can obey AGENTS.md rule 5
+ * ("never `notifyDataSetChanged()` for an arbitrary list change") without each popup re-implementing
+ * the same 25-line [DiffUtil.Callback].
+ *
+ * Diffing is intentionally synchronous: every list in this app that goes through here is a
+ * user-visible sidebar/grid of at most a few hundred rows, and `DiffUtil` on 300 rows is sub-millisecond.
+ * Move detection is switched off above [MOVE_DETECTION_LIMIT] so a pathological list can never stall
+ * the UI thread (a full rebind is cheaper than an O(D²) diff walk).
+ */
+object ListDiff {
+
+ private const val MOVE_DETECTION_LIMIT = 200
+
+ fun submit(
+ adapter: RecyclerView.Adapter,
+ current: MutableList,
+ next: List,
+ idOf: (T) -> Any?,
+ contentOf: (T) -> Any? = { it },
+ contentOfOld: ((T) -> Any?)? = null,
+ contentOfNew: ((T) -> Any?)? = null
+ ) {
+ val old = current
+ if (old === next) return
+ val oldSnapshot = ArrayList(old)
+ val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
+ override fun getOldListSize(): Int = oldSnapshot.size
+ override fun getNewListSize(): Int = next.size
+
+ override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ val a = idOf(oldSnapshot[oldItemPosition])
+ val b = idOf(next[newItemPosition])
+ // Fall back to identity when a row has no usable id (e.g. a not-yet-persisted item).
+ return if (a == null || b == null) a === b else a == b
+ }
+
+ override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ val a = contentOfOld?.invoke(oldSnapshot[oldItemPosition]) ?: contentOf(oldSnapshot[oldItemPosition])
+ val b = contentOfNew?.invoke(next[newItemPosition]) ?: contentOf(next[newItemPosition])
+ return a == b
+ }
+ }, oldSnapshot.size <= MOVE_DETECTION_LIMIT)
+
+ old.clear()
+ old.addAll(next)
+ result.dispatchUpdatesTo(adapter)
+ }
+}
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..0efa0913 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
@@ -110,6 +110,25 @@ open class WebViewEx(context: Context, val callback: Callback, val jsInterface:
return callback.onRenderProcessGone(view, didCrash)
}
+ /**
+ * Per-tab desktop-mode state, pushed in by `WebViewWebEngine` (null = follow the global
+ * default). Everything that used to read `config.isDesktopMode()` here now reads this, so the
+ * zoom / UA of a backgrounded tab can no longer leak into the tab being displayed.
+ *
+ * Declared above `init {}` on purpose: the init block reads it while computing the effective
+ * user agent, and Kotlin runs property initializers in declaration order. Assigning it applies
+ * the mode, which is why there is no separate `setDesktopModeOverride()` function — a function
+ * with that name clashes with the JVM setter this property generates.
+ */
+ var desktopModeOverride: Boolean? = null
+ set(value) {
+ if (field == value) {
+ return
+ }
+ field = value
+ applyDesktopMode()
+ }
+
init {
setLayerType(View.LAYER_TYPE_HARDWARE, null)
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
@@ -128,15 +147,28 @@ open class WebViewEx(context: Context, val callback: Callback, val jsInterface:
defaultTextEncodingName = "UTF-8"
domStorageEnabled = true
databaseEnabled = true
+ // Needed for file:// pages (the bundled asset home page, local documents), but a local
+ // page must never be able to read other origins.
allowFileAccess = true
allowContentAccess = true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ allowFileAccessFromFileURLs = false
+ allowUniversalAccessFromFileURLs = false
+ }
cacheMode = WebSettings.LOAD_DEFAULT
- setRenderPriority(WebSettings.RenderPriority.HIGH)
+ // setRenderPriority(HIGH) used to be called here: it has been a documented no-op since
+ // API 18, so it is removed rather than "tuned".
+ // 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
@@ -152,7 +184,8 @@ open class WebViewEx(context: Context, val callback: Callback, val jsInterface:
setSupportMultipleWindows(true)
setNeedInitialFocus(false)
- val effectiveUa = config.userAgentString.value ?: if (config.desktopMode.value) Config.DESKTOP_UA else null
+ val effectiveUa = config.userAgentString.value
+ ?: if (config.desktopMode.value || desktopModeOverride == true) Config.DESKTOP_UA else null
if (effectiveUa != null) {
userAgentString = effectiveUa
}
@@ -269,9 +302,15 @@ open class WebViewEx(context: Context, val callback: Callback, val jsInterface:
fun renderThumbnail(bitmap: Bitmap?): Bitmap? {
if (width == 0 || height == 0) return null
var thumbnail = bitmap
- if (thumbnail == null) {
+ // The snapshot only ever feeds a small grid card, so cap it: at full view size one tab
+ // thumbnail costs megabytes of RGB_565 on a 1080p TV, and drawing the whole view tree into
+ // it happens on the UI thread.
+ val maxWidth = Config.THUMBNAIL_MAX_WIDTH_PX.coerceAtLeast(120)
+ if (thumbnail == null || thumbnail.width > maxWidth || thumbnail.height <= 0) {
+ val targetWidth = minOf(width, maxWidth)
+ val targetHeight = (height.toFloat() * targetWidth / width.toFloat()).toInt().coerceAtLeast(1)
try {
- thumbnail = createBitmap(width, height, Bitmap.Config.RGB_565)
+ thumbnail = createBitmap(targetWidth, targetHeight, Bitmap.Config.RGB_565)
} catch (e: Throwable) {
e.printStackTrace()
}
@@ -408,6 +447,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
@@ -417,11 +476,12 @@ open class WebViewEx(context: Context, val callback: Callback, val jsInterface:
}
fun isDesktopModeEnabled(): Boolean {
- return config.isDesktopMode()
+ return desktopModeOverride ?: config.isDesktopMode()
}
fun applyDesktopMode() {
val isDesktop = isDesktopModeEnabled()
+ // A user-configured custom UA always wins over the desktop switch (same rule as Chrome).
val effectiveUa = config.userAgentString.value ?: if (isDesktop) Config.DESKTOP_UA else null
settings.userAgentString = effectiveUa
val targetZoom = config.getEffectiveZoom(isDesktop)
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..7c677161 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
@@ -101,6 +101,15 @@ class WebViewWebEngine(val tab: WebTabState) : WebEngine, CursorDrawerDelegate.C
pageZoomController?.zoomBy(zoomBy) ?: webView?.zoomBy(zoomBy)
}
+ // Named so it does not collide with the JVM setter of a `desktopModeOverride` property; the
+ // override is cached here because the tab can be told before its WebView exists.
+ private var pendingDesktopModeOverride: Boolean? = null
+
+ override fun setDesktopModeOverride(enabled: Boolean?) {
+ pendingDesktopModeOverride = enabled
+ webView?.desktopModeOverride = enabled
+ }
+
override fun setPageZoom(percent: Int) {
pageZoomController?.setPageZoom(percent) ?: webView?.applyZoom(percent)
}
@@ -114,7 +123,10 @@ class WebViewWebEngine(val tab: WebTabState) : WebEngine, CursorDrawerDelegate.C
if (webView == null) {
val wv = WebViewEx(activityContext, webViewCallback, jsInterface)
val cfg = AppContext.provideConfig()
- val effectiveUa = userAgentString ?: cfg.userAgentString.value ?: if (cfg.desktopMode.value) Config.DESKTOP_UA else null
+ val tabDesktop = pendingDesktopModeOverride ?: tab.desktopMode ?: cfg.desktopMode.value
+ wv.desktopModeOverride = tabDesktop
+ val effectiveUa = userAgentString ?: cfg.userAgentString.value
+ ?: if (tabDesktop) Config.DESKTOP_UA else null
userAgentString = effectiveUa
wv.settings.userAgentString = effectiveUa
val pzc = PageZoomController(wv, tab)
@@ -286,7 +298,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/color/selector_settings_segment_text.xml b/app/src/main/res/color/selector_settings_segment_text.xml
new file mode 100644
index 00000000..96e07c49
--- /dev/null
+++ b/app/src/main/res/color/selector_settings_segment_text.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_badge_rounded.xml b/app/src/main/res/drawable/bg_badge_rounded.xml
index 24bfec2e..6e759ba5 100644
--- a/app/src/main/res/drawable/bg_badge_rounded.xml
+++ b/app/src/main/res/drawable/bg_badge_rounded.xml
@@ -2,5 +2,5 @@
-
+
diff --git a/app/src/main/res/drawable/bg_badge_yellow.xml b/app/src/main/res/drawable/bg_badge_yellow.xml
index a4778139..c1bf5287 100644
--- a/app/src/main/res/drawable/bg_badge_yellow.xml
+++ b/app/src/main/res/drawable/bg_badge_yellow.xml
@@ -2,7 +2,7 @@
-
+
-
-
+
+
-
-
-
+
+
-
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_card_tab_selector.xml b/app/src/main/res/drawable/bg_card_tab_selector.xml
index 650fd134..37099405 100644
--- a/app/src/main/res/drawable/bg_card_tab_selector.xml
+++ b/app/src/main/res/drawable/bg_card_tab_selector.xml
@@ -3,22 +3,22 @@
-
-
-
+
+
-
-
-
+
+
-
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_chrome_close_btn.xml b/app/src/main/res/drawable/bg_chrome_close_btn.xml
index 91b76c64..cf35eb49 100644
--- a/app/src/main/res/drawable/bg_chrome_close_btn.xml
+++ b/app/src/main/res/drawable/bg_chrome_close_btn.xml
@@ -2,14 +2,12 @@
-
-
-
+
-
-
-
+
-
diff --git a/app/src/main/res/drawable/bg_chrome_history_card.xml b/app/src/main/res/drawable/bg_chrome_history_card.xml
index 3f3eb7ba..8887e821 100644
--- a/app/src/main/res/drawable/bg_chrome_history_card.xml
+++ b/app/src/main/res/drawable/bg_chrome_history_card.xml
@@ -2,30 +2,30 @@
-
-
+
-
+
-
-
+
-
+
-
-
+
-
+
-
-
+
-
+
diff --git a/app/src/main/res/drawable/bg_chrome_history_header.xml b/app/src/main/res/drawable/bg_chrome_history_header.xml
index a693ba0a..9cb17690 100644
--- a/app/src/main/res/drawable/bg_chrome_history_header.xml
+++ b/app/src/main/res/drawable/bg_chrome_history_header.xml
@@ -1,7 +1,7 @@
-
+
-
+
diff --git a/app/src/main/res/drawable/bg_chrome_history_sidebar_item.xml b/app/src/main/res/drawable/bg_chrome_history_sidebar_item.xml
index e503affc..75136986 100644
--- a/app/src/main/res/drawable/bg_chrome_history_sidebar_item.xml
+++ b/app/src/main/res/drawable/bg_chrome_history_sidebar_item.xml
@@ -2,21 +2,21 @@
-
-
+
-
+
-
-
+
-
+
-
-
+
diff --git a/app/src/main/res/drawable/bg_chrome_new_tab_btn.xml b/app/src/main/res/drawable/bg_chrome_new_tab_btn.xml
index e10d4b14..340f170f 100644
--- a/app/src/main/res/drawable/bg_chrome_new_tab_btn.xml
+++ b/app/src/main/res/drawable/bg_chrome_new_tab_btn.xml
@@ -3,20 +3,20 @@
-
-
+
-
-
-
+
+
-
-
+
diff --git a/app/src/main/res/drawable/bg_chrome_search_bar.xml b/app/src/main/res/drawable/bg_chrome_search_bar.xml
index 358d0847..ee7c9742 100644
--- a/app/src/main/res/drawable/bg_chrome_search_bar.xml
+++ b/app/src/main/res/drawable/bg_chrome_search_bar.xml
@@ -2,16 +2,16 @@
-
-
+
-
+
-
-
+
-
+
diff --git a/app/src/main/res/drawable/bg_chrome_tabs_segmented.xml b/app/src/main/res/drawable/bg_chrome_tabs_segmented.xml
index 568525b6..1e53eb74 100644
--- a/app/src/main/res/drawable/bg_chrome_tabs_segmented.xml
+++ b/app/src/main/res/drawable/bg_chrome_tabs_segmented.xml
@@ -2,5 +2,5 @@
-
+
diff --git a/app/src/main/res/drawable/bg_circle_mic.xml b/app/src/main/res/drawable/bg_circle_mic.xml
index 99d76bc9..8d4bc70a 100644
--- a/app/src/main/res/drawable/bg_circle_mic.xml
+++ b/app/src/main/res/drawable/bg_circle_mic.xml
@@ -2,19 +2,19 @@
-
-
-
+
+
-
-
+
-
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_history_item.xml b/app/src/main/res/drawable/bg_history_item.xml
index cfc148c3..4fc2db62 100644
--- a/app/src/main/res/drawable/bg_history_item.xml
+++ b/app/src/main/res/drawable/bg_history_item.xml
@@ -3,20 +3,20 @@
-
-
-
+
+
-
-
+
-
-
+
diff --git a/app/src/main/res/drawable/bg_incognito_card.xml b/app/src/main/res/drawable/bg_incognito_card.xml
index 62070aa1..026a5864 100644
--- a/app/src/main/res/drawable/bg_incognito_card.xml
+++ b/app/src/main/res/drawable/bg_incognito_card.xml
@@ -1,7 +1,7 @@
-
+
-
+
diff --git a/app/src/main/res/drawable/bg_incognito_circle.xml b/app/src/main/res/drawable/bg_incognito_circle.xml
index ac379231..3c3969b6 100644
--- a/app/src/main/res/drawable/bg_incognito_circle.xml
+++ b/app/src/main/res/drawable/bg_incognito_circle.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app/src/main/res/drawable/bg_menu_button_focused.xml b/app/src/main/res/drawable/bg_menu_button_focused.xml
index 11ab5a31..0d2d979d 100644
--- a/app/src/main/res/drawable/bg_menu_button_focused.xml
+++ b/app/src/main/res/drawable/bg_menu_button_focused.xml
@@ -3,19 +3,19 @@
-
-
+
-
-
+
-
-
+
diff --git a/app/src/main/res/drawable/bg_modal_dialog_card.xml b/app/src/main/res/drawable/bg_modal_dialog_card.xml
index 0accf891..cc709489 100644
--- a/app/src/main/res/drawable/bg_modal_dialog_card.xml
+++ b/app/src/main/res/drawable/bg_modal_dialog_card.xml
@@ -2,6 +2,6 @@
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_pill_button.xml b/app/src/main/res/drawable/bg_pill_button.xml
index fa8e0217..f132be3c 100644
--- a/app/src/main/res/drawable/bg_pill_button.xml
+++ b/app/src/main/res/drawable/bg_pill_button.xml
@@ -3,27 +3,27 @@
-
-
-
+
+
-
-
+
-
-
+
-
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_pill_search.xml b/app/src/main/res/drawable/bg_pill_search.xml
index 25b4adcd..9e04c3fe 100644
--- a/app/src/main/res/drawable/bg_pill_search.xml
+++ b/app/src/main/res/drawable/bg_pill_search.xml
@@ -3,15 +3,15 @@
-
-
-
+
+
-
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_round_close_btn.xml b/app/src/main/res/drawable/bg_round_close_btn.xml
index 6289273c..4801526c 100644
--- a/app/src/main/res/drawable/bg_round_close_btn.xml
+++ b/app/src/main/res/drawable/bg_round_close_btn.xml
@@ -2,19 +2,19 @@
-
-
-
+
+
-
-
+
-
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_round_fab_btn.xml b/app/src/main/res/drawable/bg_round_fab_btn.xml
index 0d8cac17..8b67f4b7 100644
--- a/app/src/main/res/drawable/bg_round_fab_btn.xml
+++ b/app/src/main/res/drawable/bg_round_fab_btn.xml
@@ -2,19 +2,19 @@
-
-
-
+
+
-
-
+
-
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_search_engine_popup.xml b/app/src/main/res/drawable/bg_search_engine_popup.xml
index 39644adc..1ec675b1 100644
--- a/app/src/main/res/drawable/bg_search_engine_popup.xml
+++ b/app/src/main/res/drawable/bg_search_engine_popup.xml
@@ -1,7 +1,7 @@
-
+
-
+
diff --git a/app/src/main/res/drawable/bg_search_suggestion_item.xml b/app/src/main/res/drawable/bg_search_suggestion_item.xml
index 03bb9371..134904ae 100644
--- a/app/src/main/res/drawable/bg_search_suggestion_item.xml
+++ b/app/src/main/res/drawable/bg_search_suggestion_item.xml
@@ -3,7 +3,7 @@
android:color="@color/search_item_pressed">
-
-
+
-
diff --git a/app/src/main/res/drawable/bg_settings_segment.xml b/app/src/main/res/drawable/bg_settings_segment.xml
new file mode 100644
index 00000000..ea131a48
--- /dev/null
+++ b/app/src/main/res/drawable/bg_settings_segment.xml
@@ -0,0 +1,30 @@
+
+
+
+
-
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_tab_card_active.xml b/app/src/main/res/drawable/bg_tab_card_active.xml
index 23bafa9c..55d143c2 100644
--- a/app/src/main/res/drawable/bg_tab_card_active.xml
+++ b/app/src/main/res/drawable/bg_tab_card_active.xml
@@ -5,5 +5,5 @@
+ android:color="@color/app_accent" />
diff --git a/app/src/main/res/drawable/bg_tab_header_active.xml b/app/src/main/res/drawable/bg_tab_header_active.xml
index 6ccf0e51..983b8283 100644
--- a/app/src/main/res/drawable/bg_tab_header_active.xml
+++ b/app/src/main/res/drawable/bg_tab_header_active.xml
@@ -6,5 +6,5 @@
android:topRightRadius="15dp"
android:bottomLeftRadius="0dp"
android:bottomRightRadius="0dp" />
-
+
diff --git a/app/src/main/res/drawable/bg_tv_floating_panel.xml b/app/src/main/res/drawable/bg_tv_floating_panel.xml
index 838e9e37..f099d5da 100644
--- a/app/src/main/res/drawable/bg_tv_floating_panel.xml
+++ b/app/src/main/res/drawable/bg_tv_floating_panel.xml
@@ -3,5 +3,5 @@
android:shape="rectangle">
-
+
diff --git a/app/src/main/res/drawable/bg_tv_side_panel.xml b/app/src/main/res/drawable/bg_tv_side_panel.xml
index ad25b921..f555731e 100644
--- a/app/src/main/res/drawable/bg_tv_side_panel.xml
+++ b/app/src/main/res/drawable/bg_tv_side_panel.xml
@@ -3,6 +3,6 @@
android:shape="rectangle">
-
+
diff --git a/app/src/main/res/drawable/button_bg_selector.xml b/app/src/main/res/drawable/button_bg_selector.xml
index e077f6cd..4cc68271 100644
--- a/app/src/main/res/drawable/button_bg_selector.xml
+++ b/app/src/main/res/drawable/button_bg_selector.xml
@@ -3,19 +3,19 @@
-
-
+
-
-
+
-
-
+
-
diff --git a/app/src/main/res/drawable/favorite_item_view_bg_selector.xml b/app/src/main/res/drawable/favorite_item_view_bg_selector.xml
index 176c42b4..da520d70 100644
--- a/app/src/main/res/drawable/favorite_item_view_bg_selector.xml
+++ b/app/src/main/res/drawable/favorite_item_view_bg_selector.xml
@@ -3,21 +3,21 @@
android:enterFadeDuration="150" android:exitFadeDuration="500">
-
-
-
+
+
-
-
-
+
+
-
-
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 599ff81e..4c69d5d8 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -4,7 +4,7 @@
+ android:startColor="@color/app_surface_elevated"
+ android:centerColor="@color/app_accent"
+ android:endColor="@color/app_accent" />
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 3d53eacd..02c9b0ef 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -30,13 +30,13 @@
@@ -53,13 +53,13 @@
android:pathData="M43,57 C49,54 59,54 65,57" />
diff --git a/app/src/main/res/drawable/ic_settings_keyboard_mouse.xml b/app/src/main/res/drawable/ic_settings_keyboard_mouse.xml
index 2196c889..c9b28982 100644
--- a/app/src/main/res/drawable/ic_settings_keyboard_mouse.xml
+++ b/app/src/main/res/drawable/ic_settings_keyboard_mouse.xml
@@ -6,7 +6,7 @@
-
-
+
+
diff --git a/app/src/main/res/drawable/mic_btn_bg_selector.xml b/app/src/main/res/drawable/mic_btn_bg_selector.xml
index bf93f8ef..fbfe9b0c 100644
--- a/app/src/main/res/drawable/mic_btn_bg_selector.xml
+++ b/app/src/main/res/drawable/mic_btn_bg_selector.xml
@@ -3,14 +3,14 @@
-
-
+
-
-
+
diff --git a/app/src/main/res/drawable/round_icon_bg.xml b/app/src/main/res/drawable/round_icon_bg.xml
index eeb3dbec..da1da13c 100644
--- a/app/src/main/res/drawable/round_icon_bg.xml
+++ b/app/src/main/res/drawable/round_icon_bg.xml
@@ -2,12 +2,12 @@
-
-
+
-
-
+
-
diff --git a/app/src/main/res/drawable/sidebar_item_bg.xml b/app/src/main/res/drawable/sidebar_item_bg.xml
index 0824cec9..88e0b4a5 100644
--- a/app/src/main/res/drawable/sidebar_item_bg.xml
+++ b/app/src/main/res/drawable/sidebar_item_bg.xml
@@ -3,20 +3,20 @@
-
-
-
+
+
-
-
+
-
-
+
-
diff --git a/app/src/main/res/drawable/sidebar_nav_button_bg.xml b/app/src/main/res/drawable/sidebar_nav_button_bg.xml
index 8058df44..1d376853 100644
--- a/app/src/main/res/drawable/sidebar_nav_button_bg.xml
+++ b/app/src/main/res/drawable/sidebar_nav_button_bg.xml
@@ -3,15 +3,15 @@
-
-
-
+
+
-
-
+
-
diff --git a/app/src/main/res/drawable/tab_button_bg_selector.xml b/app/src/main/res/drawable/tab_button_bg_selector.xml
index 599280ec..e7840817 100644
--- a/app/src/main/res/drawable/tab_button_bg_selector.xml
+++ b/app/src/main/res/drawable/tab_button_bg_selector.xml
@@ -4,16 +4,16 @@
-
-
-
+
+
-
-
-
+
+
@@ -28,8 +28,8 @@
-
-
-
+
+
diff --git a/app/src/main/res/drawable/webtab_horizontal_bkg_selector.xml b/app/src/main/res/drawable/webtab_horizontal_bkg_selector.xml
index fe02243e..3606c301 100644
--- a/app/src/main/res/drawable/webtab_horizontal_bkg_selector.xml
+++ b/app/src/main/res/drawable/webtab_horizontal_bkg_selector.xml
@@ -4,7 +4,7 @@
-
+
diff --git a/app/src/main/res/layout/activity_app_lock.xml b/app/src/main/res/layout/activity_app_lock.xml
index 872a5b1f..6bc58d42 100644
--- a/app/src/main/res/layout/activity_app_lock.xml
+++ b/app/src/main/res/layout/activity_app_lock.xml
@@ -262,7 +262,7 @@
android:layout_weight="1"
android:background="@drawable/button_bg_selector"
android:focusable="true"
- android:text="C"
+ android:text="@string/activity_app_lock_c"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="18sp"
android:textStyle="bold"
@@ -294,7 +294,7 @@
android:layout_weight="1"
android:background="@drawable/button_bg_selector"
android:focusable="true"
- android:text="⌫"
+ android:text="@string/activity_app_lock_label"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="18sp"
android:textStyle="bold"
diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml
index 6c83126e..61cdce3c 100644
--- a/app/src/main/res/layout/activity_downloads.xml
+++ b/app/src/main/res/layout/activity_downloads.xml
@@ -32,8 +32,8 @@
android:id="@+id/tvDownloadsTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Downloads"
- android:textColor="#FFFFFF"
+ android:text="@string/downloads"
+ android:textColor="@color/app_on_surface"
android:textSize="32sp"
android:textStyle="bold" />
@@ -42,8 +42,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="Nothing in progress · 17.73 GB free"
- android:textColor="#94A3B8"
+ android:text="@string/activity_downloads_nothing_in_progress_17"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -80,9 +80,9 @@
android:focusable="true"
android:paddingStart="16dp"
android:paddingEnd="16dp"
- android:text="All"
+ android:text="@string/activity_downloads_all"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="13sp"
android:textStyle="bold" />
@@ -95,9 +95,9 @@
android:focusable="true"
android:paddingStart="16dp"
android:paddingEnd="16dp"
- android:text="Active"
+ android:text="@string/activity_downloads_active"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
+ android:background="@color/app_surface_elevated" />
@@ -178,8 +178,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
- android:text="Files you download from a page will appear here."
- android:textColor="#94A3B8"
+ android:text="@string/activity_downloads_files_you_download_from"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="14sp" />
diff --git a/app/src/main/res/layout/activity_file_viewer.xml b/app/src/main/res/layout/activity_file_viewer.xml
index d9459d20..3a4c7870 100644
--- a/app/src/main/res/layout/activity_file_viewer.xml
+++ b/app/src/main/res/layout/activity_file_viewer.xml
@@ -2,13 +2,13 @@
@@ -37,7 +37,7 @@
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
- android:textColor="#F8FAFC"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
@@ -45,7 +45,7 @@
android:id="@+id/tvViewerFileMeta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -54,7 +54,7 @@
android:layout_width="44dp"
android:layout_height="44dp"
android:background="?attr/selectableItemBackgroundBorderless"
- android:contentDescription="Share"
+ android:contentDescription="@string/activity_file_viewer_share"
android:focusable="true"
android:src="@drawable/ic_share"
android:tint="#F8FAFC" />
@@ -69,7 +69,7 @@
android:id="@+id/wvFileContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:background="#0F172A" />
+ android:background="@color/app_surface_elevated" />
@@ -81,12 +81,12 @@
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@null"
- android:hint="Search history"
+ android:hint="@string/search_history"
android:imeOptions="actionSearch"
android:inputType="text"
android:lines="1"
- android:textColor="#FFFFFF"
- android:textColorHint="#64748B"
+ android:textColor="@color/app_on_surface"
+ android:textColorHint="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -95,7 +95,7 @@
android:layout_width="32dp"
android:layout_height="32dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Clear search"
+ android:contentDescription="@string/activity_history_clear_search"
android:focusable="true"
android:padding="7dp"
android:scaleType="fitCenter"
@@ -110,7 +110,7 @@
android:layout_height="32dp"
android:layout_marginStart="2dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Voice search"
+ android:contentDescription="@string/voice_search"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -133,8 +133,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
- android:text="1 selected"
- android:textColor="#38BDF8"
+ android:text="@string/activity_history_1_selected"
+ android:textColor="@color/app_accent"
android:textSize="13sp"
android:textStyle="bold" />
@@ -149,9 +149,9 @@
android:focusable="true"
android:paddingStart="12dp"
android:paddingEnd="12dp"
- android:text="Delete"
+ android:text="@string/delete"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="12sp" />
@@ -224,8 +224,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="Chrome history"
- android:textColor="#FFFFFF"
+ android:text="@string/activity_history_chrome_history"
+ android:textColor="@color/app_on_surface"
android:textSize="13sp"
android:textStyle="bold" />
@@ -237,8 +237,8 @@
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:layout_marginStart="4dp"
- android:text="FILTERS"
- android:textColor="#64748B"
+ android:text="@string/activity_history_filters"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp"
android:textStyle="bold" />
@@ -252,9 +252,9 @@
android:gravity="start|center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
- android:text="• All time"
+ android:text="@string/activity_history_all_time"
android:textAllCaps="false"
- android:textColor="#38BDF8"
+ android:textColor="@color/app_accent"
android:textSize="12sp" />
@@ -267,9 +267,9 @@
android:gravity="start|center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
- android:text="• Today"
+ android:text="@string/activity_history_today"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -282,9 +282,9 @@
android:gravity="start|center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
- android:text="• Yesterday"
+ android:text="@string/activity_history_yesterday"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -297,9 +297,9 @@
android:gravity="start|center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
- android:text="• Older"
+ android:text="@string/activity_history_older"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
+ android:background="@color/app_surface_elevated" />
@@ -338,8 +338,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
- android:text="0 visits recorded"
- android:textColor="#64748B"
+ android:text="@string/activity_history_0_visits_recorded"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -388,8 +388,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
- android:text="Your browsing history appears here"
- android:textColor="#FFFFFF"
+ android:text="@string/activity_history_your_browsing_history_appears"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
@@ -398,8 +398,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
- android:text="Websites you visit in TV Browser will be listed here"
- android:textColor="#64748B"
+ android:text="@string/activity_history_websites_you_visit_in"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index fefd57d0..4753e421 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -46,7 +46,7 @@
android:layout_height="26dp"
android:layout_marginEnd="3dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Search and Recently Closed Tabs"
+ android:contentDescription="@string/activity_main_search_and_recently_closed"
android:focusable="true"
android:padding="5dp"
android:scaleType="fitCenter"
@@ -81,7 +81,7 @@
android:layout_height="26dp"
android:layout_marginEnd="2dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Hide Header and Tab Bar"
+ android:contentDescription="@string/activity_main_hide_header_and_tab"
android:focusable="true"
android:padding="5dp"
android:scaleType="fitCenter"
@@ -95,7 +95,7 @@
android:layout_height="26dp"
android:layout_marginEnd="0dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Exit Browser Completely"
+ android:contentDescription="@string/activity_main_exit_browser_completely"
android:focusable="true"
android:padding="5dp"
android:scaleType="fitCenter"
@@ -121,7 +121,7 @@
android:layout_height="36dp"
android:layout_marginEnd="4dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="History"
+ android:contentDescription="@string/history"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -213,7 +213,7 @@
android:layout_height="36dp"
android:layout_marginEnd="6dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Tabs Overview"
+ android:contentDescription="@string/activity_main_tabs_overview"
android:focusable="true"
android:clickable="true">
@@ -237,7 +237,7 @@
android:layout_height="36dp"
android:layout_marginEnd="4dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Downloads"
+ android:contentDescription="@string/downloads"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -250,7 +250,7 @@
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="More options"
+ android:contentDescription="@string/bottom_toolbar_more_options"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -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">
-
+
+ android:background="@color/top_bar_background"
+ android:clipChildren="false"
+ android:clipToPadding="false"
+ android:elevation="6dp"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingStart="8dp"
+ android:paddingEnd="8dp"
+ android:visibility="gone">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -125,7 +125,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Privacy & Security"
+ android:text="@string/settings_privacy_and_security"
android:textColor="@color/selector_tv_side_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -135,8 +135,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="App lock, PIN & protection"
- android:textColor="#94A3B8"
+ android:text="@string/settings_browser_side_menu_app_lock_pin_and"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -145,8 +145,8 @@
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_badge_yellow"
- android:text="PLUS"
- android:textColor="#000000"
+ android:text="@string/settings_browser_side_menu_plus"
+ android:textColor="@color/app_on_accent"
android:textSize="10sp"
android:textStyle="bold" />
@@ -197,7 +197,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Start incognito"
+ android:text="@string/settings_browser_side_menu_start_incognito"
android:textColor="@color/selector_tv_side_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -207,8 +207,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Private browsing without history"
- android:textColor="#94A3B8"
+ android:text="@string/settings_browser_side_menu_private_browsing_without_history"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -258,7 +258,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Share"
+ android:text="@string/activity_file_viewer_share"
android:textColor="@color/selector_tv_side_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -267,8 +267,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Send current link or web page"
- android:textColor="#94A3B8"
+ android:text="@string/settings_browser_side_menu_send_current_link_or"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -318,7 +318,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Bookmarks"
+ android:text="@string/bookmarks"
android:textColor="@color/selector_tv_side_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -327,8 +327,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Saved websites & quick favorites"
- android:textColor="#94A3B8"
+ android:text="@string/settings_browser_side_menu_saved_websites_and_quick"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -378,7 +378,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="History"
+ android:text="@string/history"
android:textColor="@color/selector_tv_side_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -387,8 +387,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Recently visited pages & logs"
- android:textColor="#94A3B8"
+ android:text="@string/settings_browser_side_menu_recently_visited_pages_and"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -438,7 +438,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Downloads"
+ android:text="@string/downloads"
android:textColor="@color/selector_tv_side_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -447,8 +447,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Manage downloaded files & progress"
- android:textColor="#94A3B8"
+ android:text="@string/settings_browser_side_menu_manage_downloaded_files_and"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -499,7 +499,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Desktop Mode"
+ android:text="@string/settings_browser_side_menu_desktop_mode"
android:textColor="@color/selector_tv_side_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -509,8 +509,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Request desktop site view"
- android:textColor="#94A3B8"
+ android:text="@string/settings_browser_side_menu_request_desktop_site_view"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -561,7 +561,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="All Settings"
+ android:text="@string/settings_browser_side_menu_all_settings"
android:textColor="@color/selector_tv_side_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -570,8 +570,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Preferences, engine & shortcuts"
- android:textColor="#94A3B8"
+ android:text="@string/settings_browser_side_menu_preferences_engine_and_shortcuts"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
diff --git a/app/src/main/res/layout/dialog_download_prompt.xml b/app/src/main/res/layout/dialog_download_prompt.xml
index 030eb67f..bf46a9c9 100644
--- a/app/src/main/res/layout/dialog_download_prompt.xml
+++ b/app/src/main/res/layout/dialog_download_prompt.xml
@@ -34,8 +34,8 @@
@@ -83,8 +83,8 @@
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
- android:text="file_name.apk"
- android:textColor="#FFFFFF"
+ android:text="@string/settings_download_prompt_file_name_apk"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
@@ -93,8 +93,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="123.4 MB"
- android:textColor="#94A3B8"
+ android:text="@string/settings_download_prompt_123_4_mb"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -126,8 +126,8 @@
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
- android:text="Download/file_name.apk"
- android:textColor="#94A3B8"
+ android:text="@string/settings_download_prompt_download_file_name_apk"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -148,9 +148,9 @@
android:focusable="true"
android:paddingStart="24dp"
android:paddingEnd="24dp"
- android:text="Cancel"
+ android:text="@string/cancel"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="14sp" />
diff --git a/app/src/main/res/layout/dialog_favorites.xml b/app/src/main/res/layout/dialog_favorites.xml
index 67bafd6e..b7aa4e83 100644
--- a/app/src/main/res/layout/dialog_favorites.xml
+++ b/app/src/main/res/layout/dialog_favorites.xml
@@ -30,8 +30,8 @@
android:id="@+id/tvBookmarksTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Bookmarks"
- android:textColor="#FFFFFF"
+ android:text="@string/bookmarks"
+ android:textColor="@color/app_on_surface"
android:textSize="32sp"
android:textStyle="bold" />
@@ -40,8 +40,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="0 saved"
- android:textColor="#94A3B8"
+ android:text="@string/settings_favorites_0_saved"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -71,12 +71,12 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@null"
- android:hint="Search bookmarks"
+ android:hint="@string/settings_favorites_search_bookmarks"
android:imeOptions="actionSearch"
android:inputType="text"
android:lines="1"
- android:textColor="#FFFFFF"
- android:textColorHint="#64748B"
+ android:textColor="@color/app_on_surface"
+ android:textColorHint="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -92,9 +92,9 @@
android:focusable="true"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="Recently added"
+ android:text="@string/settings_favorites_recently_added"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="12sp" />
@@ -107,9 +107,9 @@
android:focusable="true"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="+ Add"
+ android:text="@string/settings_favorites_add"
android:textAllCaps="false"
- android:textColor="#0284C7"
+ android:textColor="@color/app_accent"
android:textSize="12sp"
android:textStyle="bold" />
@@ -154,8 +154,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="All bookmarks"
- android:textColor="#FFFFFF"
+ android:text="@string/settings_favorites_all_bookmarks"
+ android:textColor="@color/app_on_surface"
android:textSize="15sp"
android:textStyle="bold" />
@@ -163,8 +163,8 @@
android:id="@+id/tvSectionCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="0 items"
- android:textColor="#94A3B8"
+ android:text="@string/settings_favorites_0_items"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -211,8 +211,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
- android:text="No bookmarks yet"
- android:textColor="#FFFFFF"
+ android:text="@string/no_bookmarks_yet"
+ android:textColor="@color/app_on_surface"
android:textSize="20sp"
android:textStyle="bold" />
@@ -220,8 +220,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
- android:text="Bookmark pages to easily access them here."
- android:textColor="#94A3B8"
+ android:text="@string/settings_favorites_bookmark_pages_to_easily"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="14sp" />
diff --git a/app/src/main/res/layout/dialog_info_reader.xml b/app/src/main/res/layout/dialog_info_reader.xml
index 91c98d1f..a92dcc60 100644
--- a/app/src/main/res/layout/dialog_info_reader.xml
+++ b/app/src/main/res/layout/dialog_info_reader.xml
@@ -55,7 +55,7 @@
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Close"
+ android:contentDescription="@string/close"
android:focusable="true"
android:padding="6dp"
android:src="@drawable/ic_close_grey_900_24dp"
@@ -105,7 +105,7 @@
android:focusable="true"
android:paddingStart="16dp"
android:paddingEnd="16dp"
- android:text="Open Website"
+ android:text="@string/settings_info_reader_open_website"
android:textColor="@color/progressbar_tint"
android:textSize="13sp"
android:textStyle="bold"
diff --git a/app/src/main/res/layout/dialog_new_favorite_item.xml b/app/src/main/res/layout/dialog_new_favorite_item.xml
index cd15bfd5..465ad3f5 100644
--- a/app/src/main/res/layout/dialog_new_favorite_item.xml
+++ b/app/src/main/res/layout/dialog_new_favorite_item.xml
@@ -43,7 +43,7 @@
@@ -39,7 +39,7 @@
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
- android:textColor="#F8FAFC"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Document.pdf" />
@@ -48,7 +48,7 @@
android:id="@+id/tvPdfPageCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp"
tools:text="Page 1 of 12 • 2.4 MB" />
@@ -59,7 +59,7 @@
android:layout_height="44dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
- android:contentDescription="Zoom Out"
+ android:contentDescription="@string/settings_pdf_viewer_zoom_out"
android:focusable="true"
android:src="@drawable/ic_remove"
android:tint="#F8FAFC" />
@@ -70,7 +70,7 @@
android:layout_height="44dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
- android:contentDescription="Zoom In"
+ android:contentDescription="@string/settings_pdf_viewer_zoom_in"
android:focusable="true"
android:src="@drawable/ic_add"
android:tint="#F8FAFC" />
@@ -81,7 +81,7 @@
android:layout_height="44dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
- android:contentDescription="Share"
+ android:contentDescription="@string/activity_file_viewer_share"
android:focusable="true"
android:src="@drawable/ic_share"
android:tint="#F8FAFC" />
diff --git a/app/src/main/res/layout/dialog_settings.xml b/app/src/main/res/layout/dialog_settings.xml
index a3b0ced4..ab731440 100644
--- a/app/src/main/res/layout/dialog_settings.xml
+++ b/app/src/main/res/layout/dialog_settings.xml
@@ -32,7 +32,7 @@
android:layout_height="44dp"
android:background="@drawable/bg_google_circle_btn"
android:clickable="true"
- android:contentDescription="Close Settings"
+ android:contentDescription="@string/settings_close_settings"
android:focusable="true"
android:padding="10dp"
android:scaleType="fitCenter"
@@ -55,7 +55,7 @@
android:layout_height="40dp"
android:background="@drawable/button_bg_selector"
android:clickable="true"
- android:contentDescription="Help"
+ android:contentDescription="@string/settings_help"
android:focusable="true"
android:padding="8dp"
android:scaleType="fitCenter"
@@ -63,6 +63,115 @@
android:tint="@color/day_night_text_secondary" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -176,7 +285,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Search Engine"
+ android:text="@string/engine"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -186,7 +295,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Google, DuckDuckGo, Bing, Custom"
+ android:text="@string/settings_google_duckduckgo_bing_custom"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -218,7 +327,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Display & UI Scale"
+ android:text="@string/settings_display_and_ui_scale"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -228,7 +337,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Interface scale, orientation & font size"
+ android:text="@string/settings_interface_scale_orientation_and"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -260,7 +369,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Web Page Zoom"
+ android:text="@string/settings_web_page_zoom"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -270,7 +379,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Default page magnification & zoom steps"
+ android:text="@string/settings_default_page_magnification_and"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -302,7 +411,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Themes & Appearance"
+ android:text="@string/settings_themes_and_appearance"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -312,7 +421,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Dark mode, AMOLED black, light theme"
+ android:text="@string/settings_dark_mode_amoled_black"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -344,7 +453,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Home Page & Startup"
+ android:text="@string/settings_home_page_and_startup"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -354,7 +463,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="New tab grid, custom URL, shortcuts"
+ android:text="@string/settings_new_tab_grid_custom"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -369,7 +478,7 @@
android:paddingStart="8dp"
android:paddingTop="20dp"
android:paddingBottom="8dp"
- android:text="Privacy & Security"
+ android:text="@string/settings_privacy_and_security"
android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold" />
@@ -402,7 +511,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Ad & Tracker Blocker"
+ android:text="@string/settings_ad_and_tracker_blocker"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -412,7 +521,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="EasyList filter rules, auto-updates & shields"
+ android:text="@string/settings_easylist_filter_rules_auto"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -444,7 +553,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="App Lock & PIN Security"
+ android:text="@string/settings_app_lock_and_pin"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -454,7 +563,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Passcode protection, incognito lock"
+ android:text="@string/settings_passcode_protection_incognito_lock"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -486,7 +595,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Cache & Storage"
+ android:text="@string/settings_cache_and_storage"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -496,7 +605,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Clear browsing history, cookies & cache"
+ android:text="@string/settings_clear_browsing_history_cookies"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -511,7 +620,7 @@
android:paddingStart="8dp"
android:paddingTop="20dp"
android:paddingBottom="8dp"
- android:text="Browsing & Engine"
+ android:text="@string/settings_browsing_and_engine"
android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold" />
@@ -544,7 +653,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="User Agent & Identity"
+ android:text="@string/settings_user_agent_and_identity"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -554,7 +663,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Desktop mode, Mobile browser identity"
+ android:text="@string/settings_desktop_mode_mobile_browser"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -586,7 +695,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Web Engine & Rendering"
+ android:text="@string/settings_web_engine_and_rendering"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -596,7 +705,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="System WebView, GPU acceleration"
+ android:text="@string/settings_system_webview_gpu_acceleration"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -628,7 +737,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Media Playback & Video"
+ android:text="@string/settings_media_playback_and_video"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -638,7 +747,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Autoplay media, background audio, PiP"
+ android:text="@string/settings_autoplay_media_background_audio"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -670,7 +779,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Quick Tools & Utilities"
+ android:text="@string/settings_quick_tools_and_utilities"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -680,7 +789,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Keep screen on, downloads, popup block"
+ android:text="@string/settings_keep_screen_on_downloads"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -695,7 +804,7 @@
android:paddingStart="8dp"
android:paddingTop="20dp"
android:paddingBottom="8dp"
- android:text="Controls & Devices"
+ android:text="@string/settings_controls_and_devices"
android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold" />
@@ -728,7 +837,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Remote & Joystick"
+ android:text="@string/settings_remote_and_joystick"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -738,7 +847,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="D-pad navigation, analog stick, remote mapping"
+ android:text="@string/settings_d_pad_navigation_analog"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -770,7 +879,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Virtual Cursor & Physics"
+ android:text="@string/settings_virtual_cursor_and_physics"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -780,7 +889,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Speed, acceleration, cursor size & style"
+ android:text="@string/settings_speed_acceleration_cursor_size"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -812,7 +921,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Keyboard & Mouse"
+ android:text="@string/settings_keyboard_and_mouse"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -822,7 +931,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Virtual keyboard, pointer customisation"
+ android:text="@string/settings_virtual_keyboard_pointer_customisation"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -854,7 +963,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="Keyboard Shortcuts"
+ android:text="@string/settings_keyboard_shortcuts"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -864,7 +973,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Configurable hotkeys for tabs & navigation"
+ android:text="@string/settings_configurable_hotkeys_for_tabs"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -879,7 +988,7 @@
android:paddingStart="8dp"
android:paddingTop="20dp"
android:paddingBottom="8dp"
- android:text="About"
+ android:text="@string/settings_about"
android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold" />
@@ -912,7 +1021,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
- android:text="About & Version"
+ android:text="@string/settings_about_and_version"
android:textColor="@color/selector_google_item_title"
android:textSize="15sp"
android:textStyle="bold" />
@@ -922,7 +1031,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Gothwad Browser, build details, updates"
+ android:text="@string/settings_gothwad_browser_build_details"
android:textColor="@color/selector_google_item_subtitle"
android:textSize="12sp" />
@@ -959,7 +1068,7 @@
android:layout_height="44dp"
android:background="@drawable/bg_google_circle_btn"
android:clickable="true"
- android:contentDescription="Back to Settings Categories"
+ android:contentDescription="@string/settings_back_to_settings_categories"
android:focusable="true"
android:padding="10dp"
android:scaleType="fitCenter"
@@ -977,7 +1086,7 @@
android:id="@+id/tvSettingsDetailTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Setting"
+ android:text="@string/settings_setting"
android:textColor="@color/google_text_primary"
android:textSize="20sp"
android:textStyle="bold" />
@@ -987,7 +1096,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="Preferences"
+ android:text="@string/settings_preferences"
android:textColor="@color/google_accent"
android:textSize="11sp"
android:textStyle="bold" />
diff --git a/app/src/main/res/layout/dialog_shortcut.xml b/app/src/main/res/layout/dialog_shortcut.xml
index e03547f2..03e43e6f 100644
--- a/app/src/main/res/layout/dialog_shortcut.xml
+++ b/app/src/main/res/layout/dialog_shortcut.xml
@@ -111,7 +111,7 @@
android:paddingStart="18dp"
android:paddingEnd="18dp"
android:text="@string/clear"
- android:textColor="#EF4444"
+ android:textColor="@color/app_error"
android:textSize="13sp" />
@@ -71,8 +71,8 @@
android:paddingTop="3dp"
android:paddingEnd="8dp"
android:paddingBottom="3dp"
- android:text="0 files"
- android:textColor="#38BDF8"
+ android:text="@string/settings_sidebar_downloads_0_files"
+ android:textColor="@color/app_accent"
android:textSize="11sp"
android:textStyle="bold" />
@@ -86,7 +86,7 @@
android:minWidth="0dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
- android:text="Clear"
+ android:text="@string/clear"
android:textAllCaps="false"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="11sp" />
@@ -107,9 +107,9 @@
android:layout_marginEnd="4dp"
android:background="@drawable/bg_pill_button"
android:focusable="true"
- android:text="All"
+ android:text="@string/activity_downloads_all"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="11sp"
android:textStyle="bold" />
@@ -122,9 +122,9 @@
android:layout_marginEnd="2dp"
android:background="@drawable/bg_pill_button"
android:focusable="true"
- android:text="Active"
+ android:text="@string/activity_downloads_active"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -175,7 +175,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
- android:text="No Downloads Yet"
+ android:text="@string/settings_sidebar_downloads_no_downloads_yet"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="14sp"
android:textStyle="bold" />
@@ -184,8 +184,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
- android:text="Downloaded files will appear here"
- android:textColor="#64748B"
+ android:text="@string/settings_sidebar_downloads_downloaded_files_will_appear"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
diff --git a/app/src/main/res/layout/dialog_sidebar_favorites.xml b/app/src/main/res/layout/dialog_sidebar_favorites.xml
index f45d3cb5..ba1d2b8a 100644
--- a/app/src/main/res/layout/dialog_sidebar_favorites.xml
+++ b/app/src/main/res/layout/dialog_sidebar_favorites.xml
@@ -43,7 +43,7 @@
android:layout_height="32dp"
android:background="@drawable/bg_tv_side_back_btn"
android:clickable="true"
- android:contentDescription="Close Bookmarks"
+ android:contentDescription="@string/settings_sidebar_favorites_close_bookmarks"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -56,7 +56,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
- android:text="Bookmarks"
+ android:text="@string/bookmarks"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="16sp"
android:textStyle="bold" />
@@ -71,9 +71,9 @@
android:minWidth="0dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
- android:text="+ Add"
+ android:text="@string/settings_favorites_add"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="11sp"
android:textStyle="bold" />
@@ -87,13 +87,13 @@
android:background="@drawable/key_badge_bg"
android:drawableStart="@drawable/ic_search"
android:drawablePadding="8dp"
- android:hint="Search bookmarks..."
+ android:hint="@string/settings_sidebar_favorites_search_bookmarks"
android:imeOptions="actionSearch"
android:inputType="text"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:textColor="@color/day_night_text_color_contrast"
- android:textColorHint="#64748B"
+ android:textColorHint="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -136,7 +136,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
- android:text="No Bookmarks"
+ android:text="@string/settings_sidebar_favorites_no_bookmarks"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="14sp"
android:textStyle="bold" />
@@ -145,8 +145,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
- android:text="Click + Add to bookmark this page"
- android:textColor="#64748B"
+ android:text="@string/settings_sidebar_favorites_click_add_to_bookmark"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
diff --git a/app/src/main/res/layout/dialog_sidebar_history.xml b/app/src/main/res/layout/dialog_sidebar_history.xml
index b5404c63..fc8faaf9 100644
--- a/app/src/main/res/layout/dialog_sidebar_history.xml
+++ b/app/src/main/res/layout/dialog_sidebar_history.xml
@@ -43,7 +43,7 @@
android:layout_height="34dp"
android:background="@drawable/bg_tv_side_back_btn"
android:clickable="true"
- android:contentDescription="Close History"
+ android:contentDescription="@string/settings_sidebar_history_close_history"
android:focusable="true"
android:padding="7dp"
android:scaleType="fitCenter"
@@ -64,8 +64,8 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
- android:text="History"
- android:textColor="#FFFFFF"
+ android:text="@string/history"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
@@ -76,7 +76,7 @@
android:layout_height="32dp"
android:layout_marginEnd="6dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Open in full screen"
+ android:contentDescription="@string/settings_sidebar_history_open_in_full_screen"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -94,9 +94,9 @@
android:minWidth="0dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
- android:text="Clear Data"
+ android:text="@string/settings_sidebar_history_clear_data"
android:textAllCaps="false"
- android:textColor="#EF4444"
+ android:textColor="@color/app_error"
android:textSize="11sp"
android:textStyle="bold" />
@@ -125,8 +125,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="History tracking is paused"
- android:textColor="#F59E0B"
+ android:text="@string/settings_sidebar_history_history_tracking_is_paused"
+ android:textColor="@color/app_warning"
android:textSize="12sp"
android:textStyle="bold" />
@@ -140,9 +140,9 @@
android:minWidth="0dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
- android:text="Resume"
+ android:text="@string/settings_sidebar_history_resume"
android:textAllCaps="false"
- android:textColor="#38BDF8"
+ android:textColor="@color/app_accent"
android:textSize="11sp"
android:textStyle="bold" />
@@ -165,8 +165,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="1 selected"
- android:textColor="#38BDF8"
+ android:text="@string/activity_history_1_selected"
+ android:textColor="@color/app_accent"
android:textSize="12sp"
android:textStyle="bold" />
@@ -181,9 +181,9 @@
android:minWidth="0dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
- android:text="Select All"
+ android:text="@string/settings_sidebar_history_select_all"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -213,9 +213,9 @@
android:minWidth="0dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
- android:text="Cancel"
+ android:text="@string/cancel"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -244,12 +244,12 @@
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@android:color/transparent"
- android:hint="Search history..."
+ android:hint="@string/settings_sidebar_history_search_history"
android:imeOptions="actionSearch"
android:inputType="text"
android:singleLine="true"
android:textColor="@color/day_night_text_color_contrast"
- android:textColorHint="#64748B"
+ android:textColorHint="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -308,9 +308,9 @@
android:layout_weight="1"
android:background="@drawable/bg_pill_button"
android:focusable="true"
- android:text="Today"
+ android:text="@string/settings_sidebar_history_today"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -382,8 +382,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
- android:text="No Browsing History"
- android:textColor="#FFFFFF"
+ android:text="@string/settings_sidebar_history_no_browsing_history"
+ android:textColor="@color/app_on_surface"
android:textSize="15sp"
android:textStyle="bold" />
@@ -393,8 +393,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
- android:text="Pages you visit in Gothwad Browser will appear here with timestamps, search, and quick access."
- android:textColor="#94A3B8"
+ android:text="@string/settings_sidebar_history_pages_you_visit_in"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
diff --git a/app/src/main/res/layout/dialog_sidebar_tabs.xml b/app/src/main/res/layout/dialog_sidebar_tabs.xml
index 104809ee..4630029e 100644
--- a/app/src/main/res/layout/dialog_sidebar_tabs.xml
+++ b/app/src/main/res/layout/dialog_sidebar_tabs.xml
@@ -43,7 +43,7 @@
android:layout_height="32dp"
android:background="@drawable/bg_tv_side_back_btn"
android:clickable="true"
- android:contentDescription="Close Tabs"
+ android:contentDescription="@string/settings_sidebar_tabs_close_tabs"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -56,7 +56,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
- android:text="Open Tabs"
+ android:text="@string/settings_sidebar_tabs_open_tabs"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="16sp"
android:textStyle="bold" />
@@ -72,9 +72,9 @@
android:minWidth="0dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
- android:text="+ New"
+ android:text="@string/settings_sidebar_tabs_new"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="11sp"
android:textStyle="bold" />
@@ -88,9 +88,9 @@
android:minWidth="0dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
- android:text="Close All"
+ android:text="@string/settings_sidebar_tabs_close_all"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
diff --git a/app/src/main/res/layout/dialog_tab_search_dropdown.xml b/app/src/main/res/layout/dialog_tab_search_dropdown.xml
index 5b3ac388..73f68e4e 100644
--- a/app/src/main/res/layout/dialog_tab_search_dropdown.xml
+++ b/app/src/main/res/layout/dialog_tab_search_dropdown.xml
@@ -43,7 +43,7 @@
android:layout_height="32dp"
android:background="@drawable/bg_tv_side_back_btn"
android:clickable="true"
- android:contentDescription="Close"
+ android:contentDescription="@string/close"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -56,7 +56,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
- android:text="Search Tabs"
+ android:text="@string/settings_tab_search_dropdown_search_tabs"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="16sp"
android:textStyle="bold" />
@@ -71,9 +71,9 @@
android:minWidth="0dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
- android:text="+ New Tab"
+ android:text="@string/settings_tab_search_dropdown_new_tab"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="11sp"
android:textStyle="bold" />
@@ -101,7 +101,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
- android:hint="Search open and recently closed tabs..."
+ android:hint="@string/settings_tab_search_dropdown_search_open_and_recently"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1"
diff --git a/app/src/main/res/layout/dialog_tabs_grid.xml b/app/src/main/res/layout/dialog_tabs_grid.xml
index 16832b9a..960360db 100644
--- a/app/src/main/res/layout/dialog_tabs_grid.xml
+++ b/app/src/main/res/layout/dialog_tabs_grid.xml
@@ -24,7 +24,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="📑 Open Tabs"
+ android:text="@string/settings_sidebar_tabs_open_tabs"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="18sp"
android:textStyle="bold" />
@@ -39,7 +39,7 @@
android:focusable="true"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="+ New Tab"
+ android:text="@string/settings_tab_search_dropdown_new_tab"
android:textAllCaps="false"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp"
@@ -55,7 +55,7 @@
android:focusable="true"
android:paddingStart="12dp"
android:paddingEnd="12dp"
- android:text="Close All"
+ android:text="@string/settings_sidebar_tabs_close_all"
android:textAllCaps="false"
android:textColor="@color/day_night_text_secondary"
android:textSize="13sp" />
@@ -69,7 +69,7 @@
android:focusable="true"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="Done"
+ android:text="@string/done"
android:textAllCaps="false"
android:textColor="@color/progressbar_tint"
android:textSize="13sp"
diff --git a/app/src/main/res/layout/dialog_tabs_row.xml b/app/src/main/res/layout/dialog_tabs_row.xml
index a21757c6..fbf98ad8 100644
--- a/app/src/main/res/layout/dialog_tabs_row.xml
+++ b/app/src/main/res/layout/dialog_tabs_row.xml
@@ -29,8 +29,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="Tabs"
- android:textColor="#FFFFFF"
+ android:text="@string/tabs"
+ android:textColor="@color/app_on_surface"
android:textSize="20sp"
android:textStyle="bold" />
@@ -43,9 +43,9 @@
android:focusable="true"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="+ New Tab"
+ android:text="@string/settings_tab_search_dropdown_new_tab"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="12sp"
android:textStyle="bold" />
@@ -58,9 +58,9 @@
android:focusable="true"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="Close All"
+ android:text="@string/settings_sidebar_tabs_close_all"
android:textAllCaps="false"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -26,8 +26,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
- android:text="Enter new 4-digit PIN"
- android:textColor="#94A3B8"
+ android:text="@string/settings_tv_pin_enter_new_4_digit"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -93,7 +93,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="1"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusRight="@+id/btnDlgKey2"
@@ -109,7 +109,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="2"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusLeft="@+id/btnDlgKey1"
@@ -125,7 +125,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="3"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusLeft="@+id/btnDlgKey2"
@@ -148,7 +148,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="4"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey1"
@@ -165,7 +165,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="5"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey2"
@@ -182,7 +182,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="6"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey3"
@@ -206,7 +206,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="7"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey4"
@@ -223,7 +223,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="8"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey5"
@@ -240,7 +240,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="9"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey6"
@@ -262,8 +262,8 @@
android:layout_weight="1"
android:background="@drawable/button_bg_selector"
android:focusable="true"
- android:text="C"
- android:textColor="#FFFFFF"
+ android:text="@string/activity_app_lock_c"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey7"
@@ -280,7 +280,7 @@
android:background="@drawable/button_bg_selector"
android:focusable="true"
android:text="0"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey8"
@@ -296,8 +296,8 @@
android:layout_weight="1"
android:background="@drawable/button_bg_selector"
android:focusable="true"
- android:text="⌫"
- android:textColor="#FFFFFF"
+ android:text="@string/activity_app_lock_label"
+ android:textColor="@color/app_on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey9"
@@ -314,8 +314,8 @@
android:layout_marginTop="16dp"
android:background="@drawable/button_bg_selector"
android:focusable="true"
- android:text="Cancel"
- android:textColor="#EF4444"
+ android:text="@string/cancel"
+ android:textColor="@color/app_error"
android:textSize="14sp"
android:textStyle="bold"
android:nextFocusUp="@+id/btnDlgKey0" />
diff --git a/app/src/main/res/layout/dialog_voice_search.xml b/app/src/main/res/layout/dialog_voice_search.xml
index 946845f9..9031ceec 100644
--- a/app/src/main/res/layout/dialog_voice_search.xml
+++ b/app/src/main/res/layout/dialog_voice_search.xml
@@ -28,7 +28,7 @@
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:text="@string/voice_search"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="20sp"
android:textStyle="bold" />
@@ -39,7 +39,7 @@
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Close"
+ android:contentDescription="@string/close"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
@@ -92,7 +92,7 @@
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/voice_search_listening"
- android:textColor="#388BFD"
+ android:textColor="@color/app_accent"
android:textSize="17sp"
android:textStyle="bold" />
@@ -104,7 +104,7 @@
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/voice_search_speak_now"
- android:textColor="#8B949E"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -123,9 +123,9 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
- android:hint="Speak to search or type URL…"
- android:textColor="#FFFFFF"
- android:textColorHint="#484F58"
+ android:hint="@string/settings_voice_search_speak_to_search_or"
+ android:textColor="@color/app_on_surface"
+ android:textColorHint="@color/app_surface_elevated"
android:textSize="17sp"
android:textStyle="bold" />
@@ -149,7 +149,7 @@
android:paddingEnd="16dp"
android:text="@android:string/cancel"
android:textAllCaps="false"
- android:textColor="#8B949E"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="14sp" />
diff --git a/app/src/main/res/layout/item_bookmark_card.xml b/app/src/main/res/layout/item_bookmark_card.xml
index afa628aa..9f6cb300 100644
--- a/app/src/main/res/layout/item_bookmark_card.xml
+++ b/app/src/main/res/layout/item_bookmark_card.xml
@@ -28,8 +28,8 @@
android:paddingEnd="8dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
- android:text="page"
- android:textColor="#94A3B8"
+ android:text="@string/item_bookmark_card_page"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="10sp"
android:textStyle="bold" />
@@ -75,8 +75,8 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
- android:text="WhatsApp"
- android:textColor="#FFFFFF"
+ android:text="@string/item_bookmark_card_whatsapp"
+ android:textColor="@color/app_on_surface"
android:textSize="15sp"
android:textStyle="bold" />
@@ -101,8 +101,8 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
- android:text="web.whatsapp.com"
- android:textColor="#94A3B8"
+ android:text="@string/item_bookmark_card_web_whatsapp_com"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
diff --git a/app/src/main/res/layout/item_chrome_tab_grid.xml b/app/src/main/res/layout/item_chrome_tab_grid.xml
index dc3749b4..363fa375 100644
--- a/app/src/main/res/layout/item_chrome_tab_grid.xml
+++ b/app/src/main/res/layout/item_chrome_tab_grid.xml
@@ -29,7 +29,7 @@
android:layout_height="18dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_tab_default_favicon"
- android:contentDescription="Favicon" />
+ android:contentDescription="@string/item_chrome_tab_grid_favicon" />
@@ -74,7 +74,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
- android:contentDescription="Tab preview" />
+ android:contentDescription="@string/item_chrome_tab_grid_tab_preview" />
@@ -35,7 +35,7 @@
android:paddingTop="2dp"
android:paddingEnd="8dp"
android:paddingBottom="2dp"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp"
tools:text="Page 1" />
diff --git a/app/src/main/res/layout/item_search_suggestion.xml b/app/src/main/res/layout/item_search_suggestion.xml
index 2830194a..bdd1444a 100644
--- a/app/src/main/res/layout/item_search_suggestion.xml
+++ b/app/src/main/res/layout/item_search_suggestion.xml
@@ -25,7 +25,7 @@
android:src="@drawable/ic_history_grey_900_36dp"
android:tint="#9AA0A6"
android:scaleType="fitCenter"
- android:contentDescription="History query" />
+ android:contentDescription="@string/item_search_suggestion_history_query" />
@@ -51,7 +51,7 @@
android:tint="#9AA0A6"
android:padding="8dp"
android:scaleType="fitCenter"
- android:contentDescription="Insert into search bar"
+ android:contentDescription="@string/item_search_suggestion_insert_into_search_bar"
android:focusable="true"
android:clickable="true" />
@@ -61,5 +61,5 @@
android:id="@+id/vSuggestionDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
- android:background="#131314" />
+ android:background="@color/app_surface_elevated" />
diff --git a/app/src/main/res/layout/item_sidebar_download.xml b/app/src/main/res/layout/item_sidebar_download.xml
index 6b733e6f..a35e85de 100644
--- a/app/src/main/res/layout/item_sidebar_download.xml
+++ b/app/src/main/res/layout/item_sidebar_download.xml
@@ -38,7 +38,7 @@
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
- android:text="sample_video.mp4"
+ android:text="@string/item_sidebar_download_sample_video_mp4"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp"
android:textStyle="bold" />
@@ -48,8 +48,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="Completed • 14.2 MB"
- android:textColor="#94A3B8"
+ android:text="@string/item_sidebar_download_completed_14_2_mb"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -59,7 +59,7 @@
android:layout_height="28dp"
android:background="@drawable/bg_tv_side_back_btn"
android:clickable="true"
- android:contentDescription="Delete download"
+ android:contentDescription="@string/item_sidebar_download_delete_download"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
diff --git a/app/src/main/res/layout/item_sidebar_favorite.xml b/app/src/main/res/layout/item_sidebar_favorite.xml
index 318abce4..52f9f94a 100644
--- a/app/src/main/res/layout/item_sidebar_favorite.xml
+++ b/app/src/main/res/layout/item_sidebar_favorite.xml
@@ -37,7 +37,7 @@
android:layout_height="22dp"
android:background="@drawable/bg_tv_side_back_btn"
android:clickable="true"
- android:contentDescription="Delete Bookmark"
+ android:contentDescription="@string/item_sidebar_favorite_delete_bookmark"
android:focusable="true"
android:padding="4dp"
android:scaleType="fitCenter"
@@ -52,7 +52,7 @@
android:layout_marginTop="6dp"
android:ellipsize="end"
android:maxLines="1"
- android:text="Google"
+ android:text="@string/item_sidebar_favorite_google"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="12sp"
android:textStyle="bold" />
@@ -64,7 +64,7 @@
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
- android:text="google.com"
- android:textColor="#64748B"
+ android:text="@string/item_sidebar_favorite_google_com"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="10sp" />
diff --git a/app/src/main/res/layout/item_sidebar_file.xml b/app/src/main/res/layout/item_sidebar_file.xml
index 44fbc3c2..d19d892f 100644
--- a/app/src/main/res/layout/item_sidebar_file.xml
+++ b/app/src/main/res/layout/item_sidebar_file.xml
@@ -32,7 +32,7 @@
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
- android:text="Download"
+ android:text="@string/download"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp"
android:textStyle="bold" />
@@ -42,8 +42,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="12 items • Today"
- android:textColor="#94A3B8"
+ android:text="@string/item_sidebar_file_12_items_today"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -53,7 +53,7 @@
android:layout_height="28dp"
android:background="@drawable/bg_tv_side_back_btn"
android:clickable="true"
- android:contentDescription="File options"
+ android:contentDescription="@string/item_sidebar_file_file_options"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
diff --git a/app/src/main/res/layout/item_sidebar_history.xml b/app/src/main/res/layout/item_sidebar_history.xml
index 1a840843..e7d8be38 100644
--- a/app/src/main/res/layout/item_sidebar_history.xml
+++ b/app/src/main/res/layout/item_sidebar_history.xml
@@ -35,7 +35,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
- android:textColor="#64748B"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp"
android:textStyle="bold"
tools:text="10:45 AM" />
@@ -70,7 +70,7 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="13sp"
android:textStyle="bold"
tools:text="Google Search" />
@@ -82,7 +82,7 @@
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp"
tools:text="https://www.google.com" />
@@ -94,7 +94,7 @@
android:layout_height="32dp"
android:layout_marginStart="4dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Delete entry"
+ android:contentDescription="@string/item_sidebar_history_delete_entry"
android:focusable="true"
android:padding="7dp"
android:scaleType="fitCenter"
@@ -108,7 +108,7 @@
android:layout_height="32dp"
android:layout_marginStart="2dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="More options"
+ android:contentDescription="@string/bottom_toolbar_more_options"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
diff --git a/app/src/main/res/layout/item_sidebar_history_header.xml b/app/src/main/res/layout/item_sidebar_history_header.xml
index a312565c..c51af6a9 100644
--- a/app/src/main/res/layout/item_sidebar_history_header.xml
+++ b/app/src/main/res/layout/item_sidebar_history_header.xml
@@ -29,7 +29,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:textColor="#F8FAFC"
+ android:textColor="@color/app_on_surface"
android:textSize="12sp"
android:textStyle="bold"
tools:text="Today – Tuesday, September 8, 2026" />
@@ -38,7 +38,7 @@
android:id="@+id/tvHeaderCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:textColor="#64748B"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp"
tools:text="3 visits" />
diff --git a/app/src/main/res/layout/item_sidebar_tab.xml b/app/src/main/res/layout/item_sidebar_tab.xml
index 7b29981f..c5053145 100644
--- a/app/src/main/res/layout/item_sidebar_tab.xml
+++ b/app/src/main/res/layout/item_sidebar_tab.xml
@@ -31,8 +31,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="ACTIVE"
- android:textColor="#38BDF8"
+ android:text="@string/item_sidebar_tab_active"
+ android:textColor="@color/app_accent"
android:textSize="9sp"
android:textStyle="bold"
android:visibility="gone" />
@@ -49,7 +49,7 @@
android:layout_height="26dp"
android:background="@drawable/bg_chrome_close_btn"
android:clickable="true"
- android:contentDescription="Close Tab"
+ android:contentDescription="@string/item_sidebar_tab_close_tab"
android:focusable="true"
android:padding="5dp"
android:scaleType="fitCenter"
@@ -64,7 +64,7 @@
android:layout_marginTop="6dp"
android:ellipsize="end"
android:maxLines="2"
- android:text="Google Search"
+ android:text="@string/item_sidebar_tab_google_search"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="12sp"
android:textStyle="bold" />
@@ -76,7 +76,7 @@
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
- android:text="google.com"
- android:textColor="#64748B"
+ android:text="@string/item_sidebar_favorite_google_com"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="10sp" />
diff --git a/app/src/main/res/layout/item_tab_grid_card.xml b/app/src/main/res/layout/item_tab_grid_card.xml
index df3af36d..dd8afe4d 100644
--- a/app/src/main/res/layout/item_tab_grid_card.xml
+++ b/app/src/main/res/layout/item_tab_grid_card.xml
@@ -36,7 +36,7 @@
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
- android:text="Tab Title"
+ android:text="@string/item_tab_grid_card_tab_title"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp"
android:textStyle="bold" />
@@ -46,7 +46,7 @@
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Close Tab"
+ android:contentDescription="@string/item_sidebar_tab_close_tab"
android:focusable="true"
android:padding="5dp"
android:scaleType="fitCenter"
@@ -94,8 +94,8 @@
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
- android:text="ACTIVE"
- android:textColor="#FFFFFF"
+ android:text="@string/item_sidebar_tab_active"
+ android:textColor="@color/app_on_surface"
android:textSize="10sp"
android:textStyle="bold"
android:visibility="gone" />
diff --git a/app/src/main/res/layout/item_tab_row_card.xml b/app/src/main/res/layout/item_tab_row_card.xml
index 99816da0..9bc154c8 100644
--- a/app/src/main/res/layout/item_tab_row_card.xml
+++ b/app/src/main/res/layout/item_tab_row_card.xml
@@ -36,8 +36,8 @@
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
- android:text="Tab Title"
- android:textColor="#FFFFFF"
+ android:text="@string/item_tab_grid_card_tab_title"
+ android:textColor="@color/app_on_surface"
android:textSize="12sp"
android:textStyle="bold" />
@@ -47,7 +47,7 @@
android:layout_height="24dp"
android:background="@drawable/bg_chrome_close_btn"
android:clickable="true"
- android:contentDescription="Close Tab"
+ android:contentDescription="@string/item_sidebar_tab_close_tab"
android:focusable="true"
android:padding="4dp"
android:scaleType="fitCenter"
@@ -61,7 +61,7 @@
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
- android:background="#161B26">
+ android:background="@color/app_surface_elevated">
@@ -110,8 +110,8 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
- android:text="google.com"
- android:textColor="#94A3B8"
+ android:text="@string/item_sidebar_favorite_google_com"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="10sp" />
diff --git a/app/src/main/res/layout/item_tab_search_row.xml b/app/src/main/res/layout/item_tab_search_row.xml
index 2d0799eb..8afb82ff 100644
--- a/app/src/main/res/layout/item_tab_search_row.xml
+++ b/app/src/main/res/layout/item_tab_search_row.xml
@@ -65,8 +65,8 @@
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
- android:text="ACTIVE"
- android:textColor="#FFFFFF"
+ android:text="@string/item_sidebar_tab_active"
+ android:textColor="@color/app_on_surface"
android:textSize="9sp"
android:textStyle="bold"
android:visibility="gone" />
@@ -78,7 +78,7 @@
android:layout_marginStart="4dp"
android:background="@drawable/bg_chrome_close_btn"
android:clickable="true"
- android:contentDescription="Close Tab"
+ android:contentDescription="@string/item_sidebar_tab_close_tab"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
diff --git a/app/src/main/res/layout/item_top_chrome_tab.xml b/app/src/main/res/layout/item_top_chrome_tab.xml
index 1cde09d7..a9c9fe41 100644
--- a/app/src/main/res/layout/item_top_chrome_tab.xml
+++ b/app/src/main/res/layout/item_top_chrome_tab.xml
@@ -22,7 +22,7 @@
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginEnd="5dp"
- android:contentDescription="Tab Icon"
+ android:contentDescription="@string/item_top_chrome_tab_tab_icon"
android:scaleType="fitCenter"
android:src="@drawable/ic_tab_default_favicon" />
@@ -34,7 +34,7 @@
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
- android:text="New Tab"
+ android:text="@string/shortcut_new_tab"
android:includeFontPadding="false"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="11sp"
@@ -47,7 +47,7 @@
android:layout_marginStart="2dp"
android:background="@drawable/bg_chrome_close_btn"
android:clickable="true"
- android:contentDescription="Close Tab"
+ android:contentDescription="@string/item_sidebar_tab_close_tab"
android:focusable="true"
android:padding="3dp"
android:scaleType="fitCenter"
diff --git a/app/src/main/res/layout/item_zip_entry.xml b/app/src/main/res/layout/item_zip_entry.xml
index 5ce5f530..50d99017 100644
--- a/app/src/main/res/layout/item_zip_entry.xml
+++ b/app/src/main/res/layout/item_zip_entry.xml
@@ -37,7 +37,7 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
- android:textColor="#F8FAFC"
+ android:textColor="@color/app_on_surface"
android:textSize="14sp"
android:textStyle="bold"
tools:text="manifest.json" />
@@ -49,7 +49,7 @@
android:layout_marginTop="2dp"
android:ellipsize="end"
android:singleLine="true"
- android:textColor="#94A3B8"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp"
tools:text="14.2 KB (Compressed: 3.1 KB • 78% ratio)" />
diff --git a/app/src/main/res/layout/popup_browser_sidebar.xml b/app/src/main/res/layout/popup_browser_sidebar.xml
index c3efe991..c8b072a3 100644
--- a/app/src/main/res/layout/popup_browser_sidebar.xml
+++ b/app/src/main/res/layout/popup_browser_sidebar.xml
@@ -37,7 +37,7 @@
@@ -45,7 +45,7 @@
@@ -100,7 +100,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
- android:text="Bookmarks"
+ android:text="@string/bookmarks"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp" />
@@ -130,7 +130,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
- android:text="History"
+ android:text="@string/history"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp" />
@@ -160,7 +160,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
- android:text="Downloads"
+ android:text="@string/downloads"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp" />
@@ -192,7 +192,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
- android:text="Incognito Mode"
+ android:text="@string/browser_sidebar_incognito_mode"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp" />
@@ -222,7 +222,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
- android:text="App Lock PIN"
+ android:text="@string/browser_sidebar_app_lock_pin"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp" />
@@ -246,7 +246,7 @@
@@ -327,7 +327,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
- android:text="Settings"
+ android:text="@string/settings"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp" />
@@ -357,7 +357,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
- android:text="About & Help"
+ android:text="@string/browser_sidebar_about_and_help"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="13sp" />
diff --git a/app/src/main/res/layout/popup_chrome_menu.xml b/app/src/main/res/layout/popup_chrome_menu.xml
index fdbb6cd2..fb47aed1 100644
--- a/app/src/main/res/layout/popup_chrome_menu.xml
+++ b/app/src/main/res/layout/popup_chrome_menu.xml
@@ -29,7 +29,7 @@
android:layout_height="36dp"
android:layout_weight="1"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Back"
+ android:contentDescription="@string/chrome_menu_back"
android:focusable="true"
android:focusableInTouchMode="true"
android:nextFocusLeft="@+id/btnMenuBack"
@@ -47,7 +47,7 @@
android:layout_height="36dp"
android:layout_weight="1"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Forward"
+ android:contentDescription="@string/chrome_menu_forward"
android:focusable="true"
android:focusableInTouchMode="true"
android:nextFocusLeft="@+id/btnMenuBack"
@@ -65,7 +65,7 @@
android:layout_height="36dp"
android:layout_weight="1"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Bookmark this page"
+ android:contentDescription="@string/chrome_menu_bookmark_this_page"
android:focusable="true"
android:focusableInTouchMode="true"
android:nextFocusLeft="@+id/btnMenuForward"
@@ -83,7 +83,7 @@
android:layout_height="36dp"
android:layout_weight="1"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Download page"
+ android:contentDescription="@string/chrome_menu_download_page"
android:focusable="true"
android:focusableInTouchMode="true"
android:nextFocusLeft="@+id/btnMenuBookmarkPage"
@@ -101,7 +101,7 @@
android:layout_height="36dp"
android:layout_weight="1"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Refresh page"
+ android:contentDescription="@string/refresh_page"
android:focusable="true"
android:focusableInTouchMode="true"
android:nextFocusLeft="@+id/btnMenuDownloadPage"
@@ -166,7 +166,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="New tab"
+ android:text="@string/bottom_toolbar_new_tab"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -202,7 +202,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="New Incognito tab"
+ android:text="@string/chrome_menu_new_incognito_tab"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -236,7 +236,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Add tab to new group"
+ android:text="@string/chrome_menu_add_tab_to_new"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -277,7 +277,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="History"
+ android:text="@string/history"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -311,7 +311,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Delete browsing data"
+ android:text="@string/chrome_menu_delete_browsing_data"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -352,7 +352,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Downloads"
+ android:text="@string/downloads"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -386,7 +386,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Bookmarks"
+ android:text="@string/bookmarks"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -420,7 +420,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Recent tabs"
+ android:text="@string/chrome_menu_recent_tabs"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -461,7 +461,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Share..."
+ android:text="@string/chrome_menu_share"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -495,7 +495,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Find in page"
+ android:text="@string/find_in_page"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -529,7 +529,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Translate..."
+ android:text="@string/chrome_menu_translate"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -563,7 +563,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Show Reading mode"
+ android:text="@string/chrome_menu_show_reading_mode"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -597,7 +597,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Install and create shortcut"
+ android:text="@string/chrome_menu_install_and_create_shortcut"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -635,7 +635,7 @@
android:layout_toStartOf="@+id/cbDesktopSite"
android:layout_centerVertical="true"
android:layout_marginStart="14dp"
- android:text="Desktop site"
+ android:text="@string/chrome_menu_desktop_site"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -686,7 +686,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Settings"
+ android:text="@string/settings"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -720,7 +720,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="Help and feedback"
+ android:text="@string/chrome_menu_help_and_feedback"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -763,7 +763,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
- android:text="App Lock & Security"
+ android:text="@string/chrome_menu_app_lock_and_security"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
diff --git a/app/src/main/res/layout/popup_chrome_tabs_menu.xml b/app/src/main/res/layout/popup_chrome_tabs_menu.xml
index c134315b..1d531c1b 100644
--- a/app/src/main/res/layout/popup_chrome_tabs_menu.xml
+++ b/app/src/main/res/layout/popup_chrome_tabs_menu.xml
@@ -27,13 +27,13 @@
android:layout_height="22dp"
android:src="@drawable/ic_add"
android:tint="@color/day_night_icon_color"
- android:contentDescription="New tab" />
+ android:contentDescription="@string/bottom_toolbar_new_tab" />
@@ -56,13 +56,13 @@
android:layout_height="22dp"
android:src="@drawable/ic_incognito"
android:tint="@color/day_night_icon_color"
- android:contentDescription="New Incognito tab" />
+ android:contentDescription="@string/chrome_menu_new_incognito_tab" />
@@ -85,13 +85,13 @@
android:layout_height="22dp"
android:src="@drawable/ic_view_grid"
android:tint="@color/day_night_icon_color"
- android:contentDescription="New tab group" />
+ android:contentDescription="@string/chrome_tabs_menu_new_tab_group" />
@@ -114,13 +114,13 @@
android:layout_height="22dp"
android:src="@drawable/ic_close_grey_900_24dp"
android:tint="@color/day_night_icon_color"
- android:contentDescription="Close all tabs" />
+ android:contentDescription="@string/chrome_tabs_menu_close_all_tabs" />
@@ -143,13 +143,13 @@
android:layout_height="22dp"
android:src="@drawable/ic_check_box_outline"
android:tint="@color/day_night_icon_color"
- android:contentDescription="Select tabs" />
+ android:contentDescription="@string/chrome_tabs_menu_select_tabs" />
@@ -172,13 +172,13 @@
android:layout_height="22dp"
android:src="@drawable/ic_delete"
android:tint="@color/day_night_icon_color"
- android:contentDescription="Delete browsing data" />
+ android:contentDescription="@string/chrome_menu_delete_browsing_data" />
@@ -201,7 +201,7 @@
android:layout_height="22dp"
android:src="@drawable/ic_settings_grey_900_24dp"
android:tint="@color/day_night_icon_color"
- android:contentDescription="Settings" />
+ android:contentDescription="@string/settings" />
@@ -54,8 +54,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
- android:text="Google"
- android:textColor="#FFFFFF"
+ android:text="@string/item_sidebar_favorite_google"
+ android:textColor="@color/app_on_surface"
android:textSize="13.5sp" />
+ android:background="@color/app_surface_elevated" />
diff --git a/app/src/main/res/layout/popup_website_menu.xml b/app/src/main/res/layout/popup_website_menu.xml
index 46b18aa3..c74b5462 100644
--- a/app/src/main/res/layout/popup_website_menu.xml
+++ b/app/src/main/res/layout/popup_website_menu.xml
@@ -29,7 +29,7 @@
android:layout_height="32dp"
android:layout_weight="1"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Site Info & Security"
+ android:contentDescription="@string/website_menu_site_info_and_security"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="6dp"
@@ -44,7 +44,7 @@
android:layout_height="32dp"
android:layout_weight="1"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Bookmark Page"
+ android:contentDescription="@string/website_menu_bookmark_page"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="6dp"
@@ -59,7 +59,7 @@
android:layout_height="32dp"
android:layout_weight="1"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Refresh page"
+ android:contentDescription="@string/refresh_page"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="6dp"
@@ -89,7 +89,7 @@
@@ -107,7 +107,7 @@
android:layout_width="34dp"
android:layout_height="32dp"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Zoom Out"
+ android:contentDescription="@string/settings_pdf_viewer_zoom_out"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="5dp"
@@ -133,7 +133,7 @@
android:layout_width="34dp"
android:layout_height="32dp"
android:background="@drawable/menu_item_selector"
- android:contentDescription="Zoom In"
+ android:contentDescription="@string/settings_pdf_viewer_zoom_in"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="5dp"
@@ -207,7 +207,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
- android:text="New tab"
+ android:text="@string/bottom_toolbar_new_tab"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -243,7 +243,7 @@
android:layout_toStartOf="@+id/cbWebMenuDesktop"
android:layout_centerVertical="true"
android:layout_marginStart="10dp"
- android:text="Desktop site"
+ android:text="@string/chrome_menu_desktop_site"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -285,7 +285,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
- android:text="Find in page"
+ android:text="@string/find_in_page"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
@@ -317,7 +317,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
- android:text="Share..."
+ android:text="@string/chrome_menu_share"
android:singleLine="true"
android:ellipsize="end"
android:textColor="@color/day_night_text_color_contrast"
diff --git a/app/src/main/res/layout/view_chrome_search_screen.xml b/app/src/main/res/layout/view_chrome_search_screen.xml
index 69d65950..c918522a 100644
--- a/app/src/main/res/layout/view_chrome_search_screen.xml
+++ b/app/src/main/res/layout/view_chrome_search_screen.xml
@@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
- android:background="#131314"
+ android:background="@color/day_night_background_color"
android:clickable="true"
android:focusable="true">
@@ -24,17 +24,17 @@
android:paddingStart="8dp"
android:paddingEnd="12dp">
-
+
@@ -48,15 +48,15 @@
android:layout_marginEnd="6dp"
android:background="@null"
android:gravity="center_vertical"
- android:hint="Search Google or type URL"
+ android:hint="@string/search_or_type_url"
android:imeOptions="actionSearch"
android:inputType="textFilter|textNoSuggestions|textUri"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:singleLine="true"
android:maxLines="1"
- android:textColor="#E3E3E3"
- android:textColorHint="#9AA0A6"
+ android:textColor="@color/app_on_surface"
+ android:textColorHint="@color/app_on_surface_muted"
android:textCursorDrawable="@null"
android:textSize="15sp" />
@@ -78,7 +78,7 @@
android:tint="#9AA0A6"
android:padding="7dp"
android:scaleType="fitCenter"
- android:contentDescription="Clear query"
+ android:contentDescription="@string/clear_query"
android:visibility="gone"
android:clickable="true"
android:focusable="true" />
@@ -97,20 +97,6 @@
android:clickable="true"
android:focusable="true" />
-
-
@@ -141,8 +127,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
- android:text="Type to search or browse history"
- android:textColor="#9AA0A6"
+ android:text="@string/search_suggestions_empty"
+ android:textColor="@color/day_night_text_secondary"
android:textSize="14sp"
android:visibility="gone" />
diff --git a/app/src/main/res/layout/view_chrome_tab_switcher.xml b/app/src/main/res/layout/view_chrome_tab_switcher.xml
index 643e15b5..cbdcc172 100644
--- a/app/src/main/res/layout/view_chrome_tab_switcher.xml
+++ b/app/src/main/res/layout/view_chrome_tab_switcher.xml
@@ -29,7 +29,7 @@
android:tint="#041E49"
android:scaleType="fitCenter"
android:padding="8dp"
- android:contentDescription="New tab"
+ android:contentDescription="@string/bottom_toolbar_new_tab"
android:focusable="true"
android:clickable="true" />
@@ -82,7 +82,7 @@
android:src="@drawable/ic_view_grid"
android:tint="@color/day_night_text_secondary"
android:scaleType="fitCenter"
- android:contentDescription="Tab groups" />
+ android:contentDescription="@string/settings_chrome_tab_switcher_tab_groups" />
@@ -98,7 +98,7 @@
android:tint="@color/day_night_icon_color"
android:scaleType="fitCenter"
android:padding="8dp"
- android:contentDescription="More tab options"
+ android:contentDescription="@string/settings_chrome_tab_switcher_more_tab_options"
android:focusable="true"
android:clickable="true" />
@@ -120,7 +120,7 @@
android:layout_height="match_parent"
android:background="@null"
android:gravity="center_vertical"
- android:hint="Search your tabs"
+ android:hint="@string/settings_chrome_tab_switcher_search_your_tabs"
android:imeOptions="actionSearch"
android:inputType="textFilter|textNoSuggestions"
android:paddingStart="16dp"
@@ -240,7 +240,7 @@
android:id="@+id/tvGroupTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="● 1 tab group"
+ android:text="@string/settings_chrome_tab_switcher_1_tab_group"
android:textColor="@color/day_night_text_color_contrast"
android:textSize="15sp"
android:textStyle="bold" />
@@ -250,7 +250,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
- android:text="Open tab group"
+ android:text="@string/settings_chrome_tab_switcher_open_tab_group"
android:textColor="@color/day_night_text_secondary"
android:textSize="13sp" />
@@ -264,7 +264,7 @@
android:tint="@color/day_night_text_secondary"
android:scaleType="fitCenter"
android:padding="6dp"
- android:contentDescription="Group options" />
+ android:contentDescription="@string/settings_chrome_tab_switcher_group_options" />
diff --git a/app/src/main/res/layout/view_download_item.xml b/app/src/main/res/layout/view_download_item.xml
index 55ba3ddc..2753b1bc 100644
--- a/app/src/main/res/layout/view_download_item.xml
+++ b/app/src/main/res/layout/view_download_item.xml
@@ -44,8 +44,8 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
- android:text="File Name"
- android:textColor="#FFFFFF"
+ android:text="@string/settings_download_item_file_name"
+ android:textColor="@color/app_on_surface"
android:textSize="15sp"
android:textStyle="bold" />
@@ -56,8 +56,8 @@
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
- android:text="https://example.com"
- android:textColor="#94A3B8"
+ android:text="@string/settings_download_item_https_example_com"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -93,7 +93,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="12:00"
- android:textColor="#64748B"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp" />
@@ -37,7 +37,7 @@
android:id="@+id/tvHeaderCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:textColor="#64748B"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp"
tools:text="2 visits" />
diff --git a/app/src/main/res/layout/view_history_item.xml b/app/src/main/res/layout/view_history_item.xml
index 761d6b92..03771b54 100644
--- a/app/src/main/res/layout/view_history_item.xml
+++ b/app/src/main/res/layout/view_history_item.xml
@@ -34,7 +34,7 @@
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:text="15:46"
- android:textColor="#64748B"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12sp"
android:textStyle="bold" />
@@ -68,8 +68,8 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
- android:text="Page Title"
- android:textColor="#FFFFFF"
+ android:text="@string/settings_history_item_page_title"
+ android:textColor="@color/app_on_surface"
android:textSize="14sp"
android:textStyle="bold" />
@@ -80,8 +80,8 @@
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
- android:text="https://example.com"
- android:textColor="#94A3B8"
+ android:text="@string/settings_download_item_https_example_com"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="11sp" />
@@ -92,7 +92,7 @@
android:layout_height="34dp"
android:layout_marginStart="6dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="Remove from history"
+ android:contentDescription="@string/settings_history_item_remove_from_history"
android:focusable="true"
android:padding="7dp"
android:scaleType="fitCenter"
@@ -106,7 +106,7 @@
android:layout_height="34dp"
android:layout_marginStart="4dp"
android:background="@drawable/button_bg_selector"
- android:contentDescription="More options"
+ android:contentDescription="@string/bottom_toolbar_more_options"
android:focusable="true"
android:padding="6dp"
android:scaleType="fitCenter"
diff --git a/app/src/main/res/layout/view_native_home.xml b/app/src/main/res/layout/view_native_home.xml
index 5f16573c..0a9c31cc 100644
--- a/app/src/main/res/layout/view_native_home.xml
+++ b/app/src/main/res/layout/view_native_home.xml
@@ -63,8 +63,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
- android:text="You've gone Incognito"
- android:textColor="#FFFFFF"
+ android:text="@string/settings_native_home_you_ve_gone_incognito"
+ android:textColor="@color/app_on_surface"
android:textSize="22sp"
android:textStyle="bold" />
@@ -75,8 +75,8 @@
android:layout_marginTop="8dp"
android:maxWidth="700dp"
android:gravity="center"
- android:text="Now you can browse privately, and other people who use this device won't see your activity. However, downloads and bookmarks will be saved."
- android:textColor="#9AA0A6"
+ android:text="@string/settings_native_home_now_you_can_browse"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
@@ -102,8 +102,8 @@
@@ -111,24 +111,24 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
- android:text="• Your browsing history"
- android:textColor="#BDC1C6"
+ android:text="@string/settings_native_home_your_browsing_history"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12.5sp" />
@@ -145,8 +145,8 @@
@@ -154,24 +154,24 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
- android:text="• Websites you visit"
- android:textColor="#BDC1C6"
+ android:text="@string/settings_native_home_websites_you_visit"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="12.5sp" />
@@ -187,9 +187,9 @@
android:focusableInTouchMode="true"
android:paddingStart="24dp"
android:paddingEnd="24dp"
- android:text="Close Incognito Mode"
+ android:text="@string/settings_native_home_close_incognito_mode"
android:textAllCaps="false"
- android:textColor="#FFFFFF"
+ android:textColor="@color/app_on_surface"
android:textSize="14sp" />
diff --git a/app/src/main/res/layout/view_settings_main.xml b/app/src/main/res/layout/view_settings_main.xml
index 1900398b..001d660d 100644
--- a/app/src/main/res/layout/view_settings_main.xml
+++ b/app/src/main/res/layout/view_settings_main.xml
@@ -24,8 +24,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🖥️ Display & UI Scaling"
- android:textColor="#38BDF8"
+ android:text="@string/settings_display_and_ui_scaling"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -106,7 +106,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ui_scale_title"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -115,7 +115,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/ui_scale_desc"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp" />
@@ -128,7 +128,7 @@
android:paddingTop="3dp"
android:paddingEnd="10dp"
android:paddingBottom="3dp"
- android:textColor="#38BDF8"
+ android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold"
tools:text="100%" />
@@ -182,7 +182,7 @@
android:layout_weight="1.4"
android:background="@drawable/bg_tv_tab_pill"
android:focusable="true"
- android:text="50% (Default)"
+ android:text="@string/settings_ui_scale_default"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="11sp" />
@@ -220,7 +220,7 @@
android:background="@drawable/bg_tv_tab_pill"
android:focusable="true"
android:text="@string/apply_ui_scale"
- android:textColor="#38BDF8"
+ android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold" />
@@ -240,8 +240,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🔍 Web Page Zoom Controls"
- android:textColor="#38BDF8"
+ android:text="@string/settings_web_page_zoom_controls"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -270,7 +270,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/web_page_zoom_title"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -279,7 +279,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/web_page_zoom_desc"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp" />
@@ -292,7 +292,7 @@
android:paddingTop="3dp"
android:paddingEnd="10dp"
android:paddingBottom="3dp"
- android:textColor="#38BDF8"
+ android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold"
tools:text="100%" />
@@ -312,7 +312,7 @@
android:layout_marginEnd="6dp"
android:background="@drawable/bg_tv_tab_pill"
android:focusable="true"
- android:text="📱 Mobile View Zoom"
+ android:text="@string/settings_mobile_view_zoom"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="12sp"
android:textStyle="bold" />
@@ -325,7 +325,7 @@
android:layout_marginStart="6dp"
android:background="@drawable/bg_tv_tab_pill"
android:focusable="true"
- android:text="🖥️ Desktop View Zoom"
+ android:text="@string/settings_desktop_view_zoom"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="12sp"
android:textStyle="bold" />
@@ -392,7 +392,7 @@
android:layout_weight="1.2"
android:background="@drawable/bg_tv_tab_pill"
android:focusable="true"
- android:text="100% (Def)"
+ android:text="@string/settings_zoom_default"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="11sp"
android:textStyle="bold" />
@@ -568,8 +568,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🎨 Themes & Appearance"
- android:textColor="#38BDF8"
+ android:text="@string/settings_themes_and_appearance"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -594,7 +594,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:text="@string/theme"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -624,8 +624,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🎬 Media & Playback"
- android:textColor="#38BDF8"
+ android:text="@string/settings_media_and_playback"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -650,7 +650,7 @@
android:layout_marginEnd="12dp"
android:layout_weight="1"
android:text="@string/allow_autoplay_media"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp" />
@@ -729,7 +729,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:text="@string/choose_default_search_engine"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -755,7 +755,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/url"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="13sp" />
+ android:textColor="@color/google_text_primary"
+ android:textColorHint="@color/app_on_surface_muted" />
@@ -791,8 +791,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🏠 Home Page & Startup"
- android:textColor="#38BDF8"
+ android:text="@string/settings_home_page_and_startup"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -814,7 +814,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:text="@string/home_page"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -840,7 +840,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/custom_home_page_url"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="13sp" />
+ android:textColor="@color/google_text_primary"
+ android:textColorHint="@color/app_on_surface_muted" />
@@ -926,8 +926,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:duplicateParentState="true"
- android:text="Make Gothwad Browser your system default browser"
- android:textColor="#94A3B8"
+ android:text="@string/settings_make_gothwad_browser_your"
+ android:textColor="@color/google_text_secondary"
android:textSize="11sp" />
@@ -948,8 +948,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🌐 User Agent & Identity"
- android:textColor="#38BDF8"
+ android:text="@string/settings_user_agent_and_identity"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -971,7 +971,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:text="@string/choose_user_agent_string"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -997,7 +997,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/user_agent_string"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="13sp" />
+ android:textColor="@color/google_text_primary"
+ android:textColorHint="@color/google_text_secondary" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1033,8 +1080,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="⚙️ Web Engine & Rendering"
- android:textColor="#38BDF8"
+ android:text="@string/settings_web_engine_and_rendering"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -1052,7 +1099,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/web_browser_engine_temporary_setting"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -1066,6 +1113,37 @@
android:focusable="true" />
+
+
+
+
+
+
+
+
@@ -1140,7 +1218,7 @@
android:layout_marginEnd="12dp"
android:layout_weight="1"
android:duplicateParentState="true"
- android:text="Enable Ad & Tracker Blocking"
+ android:text="@string/settings_enable_ad_and_tracker"
android:textColor="@color/selector_tv_setting_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -1166,8 +1244,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
- android:text="Filter List URL (EasyList format):"
- android:textColor="#94A3B8"
+ android:text="@string/settings_filter_list_url_easylist"
+ android:textColor="@color/google_text_secondary"
android:textSize="13sp" />
@@ -1238,8 +1316,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🔒 App Security & PIN Lock"
- android:textColor="#38BDF8"
+ android:text="@string/settings_app_security_and_pin"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -1267,7 +1345,7 @@
android:layout_marginEnd="12dp"
android:layout_weight="1"
android:duplicateParentState="true"
- android:text="Enable App Lock (4-Digit PIN)"
+ android:text="@string/settings_enable_app_lock_4"
android:textColor="@color/selector_tv_setting_item_text"
android:textSize="14sp"
android:textStyle="bold" />
@@ -1295,8 +1373,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="No PIN set (Set 4-digit PIN to enable)"
- android:textColor="#94A3B8"
+ android:text="@string/settings_no_pin_set_set"
+ android:textColor="@color/google_text_secondary"
android:textSize="13sp" />
@@ -1328,8 +1406,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🗑️ Cache & Storage"
- android:textColor="#38BDF8"
+ android:text="@string/settings_cache_and_storage"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -1352,8 +1430,8 @@
@@ -1361,8 +1439,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="Frees up internal TV storage and resets temporary web engine sessions."
- android:textColor="#94A3B8"
+ android:text="@string/settings_frees_up_internal_tv"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp" />
@@ -1375,7 +1453,7 @@
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="@string/clear_web_cache"
- android:textColor="#EF4444"
+ android:textColor="@color/app_error"
android:textSize="13sp"
android:textStyle="bold" />
@@ -1402,8 +1480,8 @@
@@ -1412,8 +1490,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="Automatically save visited websites to your local history. When turned off, history tracking is paused."
- android:textColor="#94A3B8"
+ android:text="@string/settings_automatically_save_visited_websites"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp" />
@@ -1446,8 +1524,8 @@
@@ -1455,8 +1533,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="Delete all visited page records and search history from this device."
- android:textColor="#94A3B8"
+ android:text="@string/settings_delete_all_visited_page"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp" />
@@ -1468,8 +1546,8 @@
android:focusable="true"
android:paddingStart="20dp"
android:paddingEnd="20dp"
- android:text="Clear History"
- android:textColor="#F59E0B"
+ android:text="@string/settings_clear_history"
+ android:textColor="@color/app_warning"
android:textSize="13sp"
android:textStyle="bold" />
@@ -1489,8 +1567,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="⚡ Quick Tools & Utilities"
- android:textColor="#38BDF8"
+ android:text="@string/settings_quick_tools_and_utilities"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -1597,7 +1675,7 @@
android:gravity="center_vertical|start"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="Rotate Screen"
+ android:text="@string/settings_rotate_screen"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="13sp"
android:textStyle="bold" />
@@ -1631,7 +1709,7 @@
android:gravity="center_vertical|start"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="Set as Default Browser"
+ android:text="@string/settings_set_as_default_browser"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="13sp"
android:textStyle="bold" />
@@ -1651,8 +1729,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="🎮 Remote & Joystick Navigation"
- android:textColor="#38BDF8"
+ android:text="@string/settings_remote_and_joystick_navigation"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -1677,7 +1755,7 @@
android:layout_marginEnd="12dp"
android:layout_weight="1"
android:text="@string/disable_virtual_keyboard"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp" />
@@ -1758,7 +1836,7 @@
android:layout_marginEnd="12dp"
android:layout_weight="1"
android:text="@string/toggle_enable_virtual_cursor"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -1782,7 +1860,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/cursor_style"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -1809,7 +1887,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/cursor_size"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp" />
@@ -1848,7 +1926,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/cursor_max_speed"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp" />
@@ -1887,7 +1965,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/cursor_acceleration"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp" />
@@ -1929,8 +2007,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="⌨️ Keyboard & Mouse Settings"
- android:textColor="#38BDF8"
+ android:text="@string/settings_keyboard_and_mouse_settings"
+ android:textColor="@color/google_accent"
android:textSize="16sp"
android:textStyle="bold" />
@@ -1946,8 +2024,8 @@
@@ -1957,8 +2035,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
- android:text="Scanning for USB / Bluetooth devices..."
- android:textColor="#94A3B8"
+ android:text="@string/settings_scanning_for_usb_bluetooth"
+ android:textColor="@color/google_text_secondary"
android:textSize="13sp" />
@@ -2010,7 +2088,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hardware_mouse_pointer_title"
- android:textColor="#FFFFFF"
+ android:textColor="@color/google_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
@@ -2019,7 +2097,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/hardware_mouse_pointer_desc"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp" />
@@ -2042,7 +2120,7 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="12dp"
- android:background="#334155" />
+ android:background="@color/app_surface_elevated" />
@@ -2064,8 +2142,8 @@
android:id="@+id/tvHardwareMouseSizeValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="100% (Normal)"
- android:textColor="#38BDF8"
+ android:text="@string/settings_percent_normal"
+ android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold" />
@@ -2085,8 +2163,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
- android:text="Quick Size Presets:"
- android:textColor="#94A3B8"
+ android:text="@string/settings_quick_size_presets"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp" />
@@ -2168,7 +2246,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/hardware_mouse_pointer_style"
- android:textColor="#E2E8F0"
+ android:textColor="@color/google_text_secondary"
android:textSize="13sp"
android:textStyle="bold" />
@@ -2185,7 +2263,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
- android:background="#0F172A"
+ android:background="@color/app_surface_elevated"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp">
@@ -2200,7 +2278,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hardware_mouse_preview"
- android:textColor="#F8FAFC"
+ android:textColor="@color/google_text_primary"
android:textSize="13sp"
android:textStyle="bold" />
@@ -2208,15 +2286,15 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
- android:text="Real-time rendered hardware cursor preview (scales smoothly from mini to huge)"
- android:textColor="#64748B"
+ android:text="@string/settings_real_time_rendered_hardware"
+ android:textColor="@color/google_text_secondary"
android:textSize="11sp" />
+ android:background="@color/app_surface_elevated">
@@ -2272,7 +2350,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:duplicateParentState="true"
- android:text="Prevents the Android TV soft keyboard from popping up when a physical keyboard is attached."
+ android:text="@string/settings_prevents_the_android_tv"
android:textColor="@color/selector_tv_setting_item_desc"
android:textSize="12sp" />
diff --git a/app/src/main/res/layout/view_settings_shortcuts.xml b/app/src/main/res/layout/view_settings_shortcuts.xml
index 2868d8b3..9f8de004 100644
--- a/app/src/main/res/layout/view_settings_shortcuts.xml
+++ b/app/src/main/res/layout/view_settings_shortcuts.xml
@@ -28,7 +28,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tab_shortcuts"
- android:textColor="#38BDF8"
+ android:textColor="@color/google_accent"
android:textSize="18sp"
android:textStyle="bold" />
@@ -37,7 +37,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/shortcuts_customization_hint"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp" />
@@ -49,8 +49,8 @@
android:focusable="true"
android:paddingStart="16dp"
android:paddingEnd="16dp"
- android:text="🔄 Reset Defaults"
- android:textColor="#38BDF8"
+ android:text="@string/settings_shortcuts_reset_defaults"
+ android:textColor="@color/google_accent"
android:textSize="12sp"
android:textStyle="bold" />
diff --git a/app/src/main/res/layout/view_settings_version.xml b/app/src/main/res/layout/view_settings_version.xml
index 4d8cff3a..a3bceca6 100644
--- a/app/src/main/res/layout/view_settings_version.xml
+++ b/app/src/main/res/layout/view_settings_version.xml
@@ -36,8 +36,8 @@
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Gothwad Browser"
- android:textColor="#38BDF8"
+ android:text="@string/app_name"
+ android:textColor="@color/google_accent"
android:textSize="22sp"
android:textStyle="bold" />
@@ -47,7 +47,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/about_tagline"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp"
android:textStyle="bold" />
@@ -60,7 +60,7 @@
android:layout_marginTop="14dp"
android:lineSpacingExtra="4dp"
android:text="@string/gothwad_tech_desc"
- android:textColor="#E2E8F0"
+ android:textColor="@color/google_text_secondary"
android:textSize="13sp" />
@@ -95,7 +95,7 @@
android:gravity="center"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="🌐 gothwadtech.com"
+ android:text="@string/settings_version_gothwadtech_com"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="12sp"
android:textStyle="bold" />
@@ -106,7 +106,7 @@
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="14dp"
- android:background="#26344D" />
+ android:background="@color/app_accent" />
@@ -134,7 +134,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp"
tools:text="Build: generic · standard" />
@@ -143,7 +143,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
- android:textColor="#94A3B8"
+ android:textColor="@color/google_text_secondary"
android:textSize="12sp"
tools:text="Engine: Android System WebView" />
@@ -162,8 +162,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
- android:text="🛡️ 100% Local & Privacy First"
- android:textColor="#38BDF8"
+ android:text="@string/settings_version_100_local_and_privacy"
+ android:textColor="@color/google_accent"
android:textSize="15sp"
android:textStyle="bold" />
@@ -171,8 +171,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lineSpacingExtra="4dp"
- android:text="• Zero Tracking: No telemetry, no background analytics, and no remote data harvesting.\n• 100% Client-Side Local: All history, bookmarks, notes, and preferences remain solely on this device.\n• Open Source: Fully transparent architecture crafted by Gothwad Tech with zero server dependencies."
- android:textColor="#CBD5E1"
+ android:text="@string/settings_version_zero_tracking_no_telemetry"
+ android:textColor="@color/app_on_surface_muted"
android:textSize="13sp" />
@@ -188,8 +188,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
- android:text="📜 Legal, Author & Organization"
- android:textColor="#38BDF8"
+ android:text="@string/settings_version_legal_author_and_organization"
+ android:textColor="@color/google_accent"
android:textSize="15sp"
android:textStyle="bold" />
@@ -212,7 +212,7 @@
android:gravity="center_vertical|start"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="🏢 About Gothwad Tech"
+ android:text="@string/about_gothwad_title"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="13sp"
android:textStyle="bold" />
@@ -229,7 +229,7 @@
android:gravity="center_vertical|start"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="🔒 Privacy Policy (In-Built)"
+ android:text="@string/settings_version_privacy_policy_in_built"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="13sp" />
@@ -253,7 +253,7 @@
android:gravity="center_vertical|start"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="📜 Terms & Conditions"
+ android:text="@string/terms_and_conditions"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="13sp" />
@@ -269,7 +269,7 @@
android:gravity="center_vertical|start"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="📃 Open Source License (MIT)"
+ android:text="@string/settings_version_open_source_license_mit"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="13sp" />
@@ -292,7 +292,7 @@
android:gravity="center_vertical|start"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="☕ Support Gothwad Tech"
+ android:text="@string/support_gothwad_tech"
android:textColor="@color/selector_tv_tab_pill_text"
android:textSize="13sp" />
@@ -308,8 +308,8 @@
android:gravity="center_vertical|start"
android:paddingStart="14dp"
android:paddingEnd="14dp"
- android:text="🌐 Visit gothwadtech.com"
- android:textColor="#38BDF8"
+ android:text="@string/settings_version_visit_gothwadtech_com"
+ android:textColor="@color/google_accent"
android:textSize="13sp"
android:textStyle="bold" />
diff --git a/app/src/main/res/layout/view_shortcut.xml b/app/src/main/res/layout/view_shortcut.xml
index 51cfc3f6..fba2be04 100644
--- a/app/src/main/res/layout/view_shortcut.xml
+++ b/app/src/main/res/layout/view_shortcut.xml
@@ -37,7 +37,7 @@
android:paddingTop="4dp"
android:paddingEnd="12dp"
android:paddingBottom="4dp"
- android:textColor="#38BDF8"
+ android:textColor="@color/app_accent"
android:textSize="12sp"
android:textStyle="bold"
tools:text="KEY_F" />
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 7dc6f74d..649bfae8 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -77,9 +77,9 @@
Version & Updates
Version: %s
Build-Variante: %1$s · %2$s
- ☕ Autor unterstützen
- 📃 Lizenz
- 🔒 Datenschutz
+ Autor unterstützen
+ Lizenz
+ Datenschutz
Updatekanal:
Automatisch nach Updates suchen
Update
@@ -103,7 +103,7 @@
Datum des Zertifikats ungültig
Ein Fehler ist aufgetreten
Zurück in Sicherheit
- Weiter, ich kenne das Risiko!
+ Weiter, ich kenne das Risiko!
Unsichere Verbindung
Hostname:
Fehlerbeschreibung:
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index c67cfcb8..357bf880 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -80,9 +80,9 @@
نسخه و به روزرسانی ها
"نسخه: %s"
نوع ساخت: %1$s · %2$s
- ☕ حمایت از نویسنده
- 📃 مجوز
- 🔒 سیاست حریم خصوصی
+ حمایت از نویسنده
+ مجوز
+ سیاست حریم خصوصی
"کانال ترجمه:"
"بررسی خودکار برای به روزرسانی ها"
"به روز رسانی"
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index b04e8ffa..54bc0654 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -79,9 +79,9 @@
Versione & Info
Versione: %s
Variante build: %1$s · %2$s
- ☕ Sostieni l\'autore
- 📃 Licenza
- 🔒 Informativa sulla privacy
+ Sostieni l\'autore
+ Licenza
+ Informativa sulla privacy
Canale aggiornamenti:
Verifica aggiornamenti
Aggiorna
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index f58b9421..0d935727 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -79,9 +79,9 @@
גרסא ועדכונים
"גרסא: %s"
גרסת בנייה: %1$s · %2$s
- ☕ תמיכה במחבר
- 📃 רישיון
- 🔒 מדיניות פרטיות
+ תמיכה במחבר
+ רישיון
+ מדיניות פרטיות
"ערוץ עדכונים:"
"בדוק אוטומטית לעדכונים:"
"עדכן:"
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 6823f2f5..e850a2f6 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -66,4 +66,16 @@
#282A2C
#1B1B1B
#2A2A2A
+
+ #202124
+ #17181A
+ #E8EAED
+ #9AA0A6
+ #3C4043
+ #8AB4F8
+ #F28B82
+ #0B0D0F
+ #FDD663
+ #81C995
+ #F28B82
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index dc33bbe6..7d9e6112 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -79,9 +79,9 @@
Wersja i aktualizacje
Wersja: %s
Wariant kompilacji: %1$s · %2$s
- ☕ Wesprzyj autora
- 📃 Licencja
- 🔒 Polityka prywatności
+ Wesprzyj autora
+ Licencja
+ Polityka prywatności
Kanał aktualizacji:
Automatycznie sprawdzaj aktualizacje
Aktualizacja
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 77fcc189..e3b59949 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -79,9 +79,9 @@
Версия
Версия: %s
Вариант сборки: %1$s · %2$s
- ☕ Поддержать автора
- 📃 Лицензия
- 🔒 Политика конфиденциальности
+ Поддержать автора
+ Лицензия
+ Политика конфиденциальности
Предупреждение
Веб-браузер, оптимизированный для телевизоров
Доступна новая версия: %s
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index f51b86f6..a9e4c2cf 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -78,9 +78,9 @@
Версія
Версія: %s
Варіант збірки: %1$s · %2$s
- ☕ Підтримати автора
- 📃 Ліцензія
- 🔒 Політика конфіденційності
+ Підтримати автора
+ Ліцензія
+ Політика конфіденційності
Увага
Веб-браузер, оптимізований для телевізорів
Доступна нова версія: %s
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 02ac9585..2d8190d9 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -76,9 +76,9 @@
Phiên bản và cập nhật
Phiên bản: %s
Biến thể bản dựng: %1$s · %2$s
- ☕ Hỗ trợ tác giả
- 📃 Giấy phép
- 🔒 Chính sách quyền riêng tư
+ Hỗ trợ tác giả
+ Giấy phép
+ Chính sách quyền riêng tư
Cập nhật kênh:
Tự động kiểm tra các bản cập nhật
Cập nhật
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index cb4e78e8..3e1fefd1 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -76,12 +76,12 @@
下載
下載狀態通知
主要
-
+ 版本 & 更新
版本:%s
建構變體:%1$s · %2$s
- ☕ 支持作者
- 📃 授權條款
- 🔒 隱私權政策
+ 支持作者
+ 授權條款
+ 隱私權政策
更新頻道:
自動檢查更新
更新
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 064d1acb..17fc318e 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -76,9 +76,9 @@
版本与更新
版本: %s
构建变体:%1$s · %2$s
- ☕ 支持作者
- 📃 许可协议
- 🔒 隐私政策
+ 支持作者
+ 许可协议
+ 隐私政策
更新频道:
自动检查更新
更新资料
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f38bee4f..b07d9f82 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -67,4 +67,18 @@
#FFFFFF
#F1F3F4
#E8EAED
+
+ #FFFFFF
+ #F1F3F4
+ #202124
+ #5F6368
+ #DADCE0
+ #1A73E8
+ #D93025
+ #FFFFFF
+ #B06000
+ #137333
+ #D93025
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0c2819e1..5b255c2d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -84,8 +84,7 @@
Pointer Size: %1$d%%
Pointer Style & Contrast
Live Preview
-
- - Classic White (Black Outline)
+ - Classic White (Black Outline)
- Dark Onyx (White Outline)
- High-Contrast Neon Yellow (TV Distance)
- Cyber Cyan (Gamer Glow)
@@ -104,12 +103,12 @@
Downloads
Notifications regarding downloads statuses
Main
-
+ Version & updates
Version: %s
Build flavor: %1$s · %2$s
- ☕ Support the author
- 📃 License
- 🔒 Privacy policy
+ Support the author
+ License
+ Privacy policy
Update channel:
Auto check for updates
Update
@@ -157,8 +156,7 @@
Crosshair Target
Glowing Red Laser
Neon Blue Pointer
-
- - @string/cursor_style_classic
+ - @string/cursor_style_classic
- @string/cursor_style_arrow
- @string/cursor_style_crosshair
- @string/cursor_style_laser
@@ -178,12 +176,12 @@
Enabling web engine debugging can expose page content, cookies, and sensitive session data to anyone with device debugging access. Enable only on trusted devices for troubleshooting.
- Auto (Follow System)
- - ☀️ Pure White Light
- - 📜 Warm Paper Light
- - 🧊 Cool Ice Light
- - 🖤 AMOLED Pitch Black
- - 🌑 Deep Charcoal Dark
- - 🌌 Midnight Slate Dark
+ - Pure White Light
+ - Warm Paper Light
+ - Cool Ice Light
+ - AMOLED Pitch Black
+ - Deep Charcoal Dark
+ - Midnight Slate Dark
Incognito mode
Now you can browse privately, and other people who use this device won’t see your activity. Browser won’t save your browsing history, cookies and site data, information entered in forms.\n\nHowever, downloads and bookmarks will be saved. Your activity might still be visible to websites you visit, your organization or internet service provider.
@@ -277,8 +275,8 @@
Created & Maintained by Gothwad Tech
GitHub: github.com/gothwadtech/tvbrowser
Official Website: gothwadtech.com
- ☕ Support Gothwad Tech
- 📜 Terms & Conditions
+ Support Gothwad Tech
+ Terms & Conditions
100% Open Source Software · Zero Server Dependencies
Press BACK again to exit
Desktop Chrome Tab Bar
@@ -287,4 +285,445 @@
Search Settings
Actions
Connection is secure
+
+ Tabs
+ New tab
+ More options
+ Search or type URL
+ Type to search or pick from your recent pages
+ Close search
+ Clear query
+ Search by voice
+ Find in page
+ Search on this page
+ Next match
+ Previous match
+ Done
+ No matches
+ Of
+ Translate this page
+ Only http/https pages can be translated
+ Could not open the translated page
+ Simplified view enabled
+ Simplified view disabled
+ Simplified view is not available on this page
+ Bookmark added: %1$s
+ Already in bookmarks
+ No open page to bookmark
+ Shortcut added to the Home screen
+ Desktop site enabled
+ Mobile site enabled
+ Saving this page\'s HTML…
+ Safe Browsing protection
+ Ask the WebView engine to check navigation and downloads against Google\'s Safe Browsing lists, like Chrome does. Disabling it is faster on very slow networks but unsafe.
+
+ Set a 4-digit PIN
+ Confirm your PIN
+ Enter your PIN
+ Verify current PIN
+ Enter a new PIN
+ Confirm the new PIN
+ Step 1 of %1$d: type a new 4-digit PIN
+ Step 2 of %1$d: type the same PIN again
+ Step 1 of %1$d: type your current PIN
+ Step 2 of %1$d: type a new 4-digit PIN
+ Step 3 of %1$d: type the new PIN again
+ PIN saved
+ PIN changed
+ The PINs did not match. Try again.
+ Incorrect PIN
+ Current PIN is incorrect
+ Too many attempts. Try again in %1$d s.
+ The browser stays unlocked for a minute after you leave it, then asks again.
+ Desktop site by default
+ New tabs start in desktop view. Turn it off for individual tabs from the page menu.
+
+ How do you browse?
+ Picks the pointer and page layout for this device. Desktop view is switched on for TV remote and keyboard & mouse.
+ Auto
+ TV remote
+ Keyboard & mouse
+ Touch
+ Input mode: %1$s
+
+ Memory: %1$s / %2$s (%3$d%%)
+ Tap for memory details
+ Storage: %1$s free
+ %1$s used (%2$d%%)
+ Memory
+ Device RAM: %1$s used of %2$s (%3$d%%)\n%4$s available
+ This app\'s heap: %1$s of %2$s limit
+ Live page views: %1$d of %2$d tabs (up to %3$d stay loaded)
+ Closing a background page view keeps the tab and its history; reopening it restores the page. No \"booster\" can return memory the system has not been asked for, so this panel only shows real numbers and real actions.
+ Free background pages
+ Clear cache
+ %1$d background page views released
+ Only the current page is loaded — nothing to release
+ Browser cache cleared
+ Back
+ Forward
+ Bookmark this page
+ Download page
+ New Incognito tab
+ Add tab to new group
+ Delete browsing data
+ Recent tabs
+ Share...
+ Translate...
+ Show Reading mode
+ Install and create shortcut
+ Desktop site
+ Help and feedback
+ App Lock & Security
+ Close Settings
+ Help
+ Basics
+ Google, DuckDuckGo, Bing, Custom
+ Display & UI Scale
+ Interface scale, orientation & font size
+ Web Page Zoom
+ Default page magnification & zoom steps
+ Themes & Appearance
+ Dark mode, AMOLED black, light theme
+ Home Page & Startup
+ New tab grid, custom URL, shortcuts
+ Privacy & Security
+ Ad & Tracker Blocker
+ EasyList filter rules, auto-updates & shields
+ App Lock & PIN Security
+ Passcode protection, incognito lock
+ Cache & Storage
+ Clear browsing history, cookies & cache
+ Browsing & Engine
+ User Agent & Identity
+ Desktop mode, Mobile browser identity
+ Web Engine & Rendering
+ System WebView, GPU acceleration
+ Media Playback & Video
+ Autoplay media, background audio, PiP
+ Quick Tools & Utilities
+ Keep screen on, downloads, popup block
+ Controls & Devices
+ Remote & Joystick
+ D-pad navigation, analog stick, remote mapping
+ Virtual Cursor & Physics
+ Speed, acceleration, cursor size & style
+ Keyboard & Mouse
+ Virtual keyboard, pointer customisation
+ Keyboard Shortcuts
+ Configurable hotkeys for tabs & navigation
+ About
+ About & Version
+ Gothwad Browser, build details, updates
+ Back to Settings Categories
+ Setting
+ Preferences
+ Display & UI Scaling
+ 50% (Default)
+ Web Page Zoom Controls
+ Mobile View Zoom
+ Desktop View Zoom
+ 100% (Def)
+ Media & Playback
+ Search Engine Settings
+ Set as Default Browser
+ Make Gothwad Browser your system default browser
+ Enable Ad & Tracker Blocking
+ Filter List URL (EasyList format):
+ Filter list loaded & active
+ Update Now
+ App Security & PIN Lock
+ Enable App Lock (4-Digit PIN)
+ No PIN set (Set 4-digit PIN to enable)
+ Change PIN
+ Clear Browser Cache & Cookies
+ Frees up internal TV storage and resets temporary web engine sessions.
+ Record Browsing History
+ Automatically save visited websites to your local history. When turned off, history tracking is paused.
+ Clear Browsing History
+ Delete all visited page records and search history from this device.
+ Clear History
+ Rotate Screen
+ Remote & Joystick Navigation
+ Keyboard & Mouse Settings
+ Connected Input Devices (Auto-Detect)
+ Scanning for USB / Bluetooth devices...
+ Refresh Device List
+ Pointer Size (Min 30% to Max 300%):
+ 100% (Normal)
+ Quick Size Presets:
+ 30% Mini
+ 60% Small
+ 100% Default
+ 150% Large
+ 250% Huge
+ Real-time rendered hardware cursor preview (scales smoothly from mini to huge)
+ Disable On-Screen Virtual Keyboard
+ Prevents the Android TV soft keyboard from popping up when a physical keyboard is attached.
+ C
+
+ Nothing in progress · 17.73 GB free
+ All
+ Active
+ Completed
+ Failed
+ No downloads
+ Files you download from a page will appear here.
+ Share
+ Clear search
+ 1 selected
+ Chrome history
+ FILTERS
+ • All time
+ • Today
+ • Yesterday
+ • Older
+ Clear browsing data
+ 0 visits recorded
+ Your browsing history appears here
+ Websites you visit in TV Browser will be listed here
+ Search and Recently Closed Tabs
+ Hide Header and Tab Bar
+ Exit Browser Completely
+ Tabs Overview
+ Add Custom Bookmark
+ Close Menu
+ Browser Menu
+ App lock, PIN & protection
+ PLUS
+ Start incognito
+ Private browsing without history
+ Send current link or web page
+ Saved websites & quick favorites
+ Recently visited pages & logs
+ Manage downloaded files & progress
+ Desktop Mode
+ Request desktop site view
+ All Settings
+ Preferences, engine & shortcuts
+ Download file
+ file_name.apk
+ 123.4 MB
+ Download/file_name.apk
+ 0 saved
+ Recently added
+ + Add
+ All bookmarks
+ 0 items
+ Bookmark pages to easily access them here.
+ Open Website
+ New Text
+ Zoom Out
+ Zoom In
+ Close Downloads
+ 0 files
+ No Downloads Yet
+ Downloaded files will appear here
+ Close Bookmarks
+ No Bookmarks
+ Click + Add to bookmark this page
+ Close History
+ Open in full screen
+ Clear Data
+ History tracking is paused
+ Resume
+ Select All
+ Today
+ Yesterday
+ Older
+ No Browsing History
+ Pages you visit in Gothwad Browser will appear here with timestamps, search, and quick access.
+ Close Tabs
+ Open Tabs
+ + New
+ Close All
+ Search Tabs
+ + New Tab
+ Close active tab
+ Set 4-Digit TV PIN
+ Enter new 4-digit PIN
+ page
+ WhatsApp
+ web.whatsapp.com
+ Favicon
+ Tab preview
+ PDF Page
+ History query
+ Insert into search bar
+ sample_video.mp4
+ Completed • 14.2 MB
+ Delete download
+ Delete Bookmark
+ Google
+ google.com
+ 12 items • Today
+ File options
+ Delete entry
+ ACTIVE
+ Close Tab
+ Google Search
+ Tab Title
+ Tab Icon
+ Fast TV Navigation
+ Incognito Mode
+ App Lock PIN
+ App UI Zoom
+ About & Help
+ New tab group
+ Close all tabs
+ Select tabs
+ Bing
+ DuckDuckGo
+ Yahoo!
+ Yandex
+ Startpage
+ Site Info & Security
+ Bookmark Page
+ Website Zoom
+ Tab groups
+ More tab options
+ ● 1 tab group
+ Open tab group
+ Group options
+ File Name
+ https://example.com
+ 0 KB
+ Page Title
+ Remove from history
+ You\'ve gone Incognito
+ Now you can browse privately, and other people who use this device won\'t see your activity. However, downloads and bookmarks will be saved.
+ Chrome won\'t save:
+ • Your browsing history
+ • Cookies and site data
+ • Information entered in forms
+ Your activity might still be visible to:
+ • Websites you visit
+ • Your employer or school
+ • Your internet service provider
+ Close Incognito Mode
+ Reset Defaults
+ github.com/gothwadtech/tvbrowser
+ gothwadtech.com
+ 100% Local & Privacy First
+ • Zero Tracking: No telemetry, no background analytics, and no remote data harvesting.\n• 100% Client-Side Local: All history, bookmarks, notes, and preferences remain solely on this device.\n• Open Source: Fully transparent architecture crafted by Gothwad Tech with zero server dependencies.
+ Legal, Author & Organization
+ Privacy Policy (In-Built)
+ Open Source License (MIT)
+ Visit gothwadtech.com
+ Navigation & Keys
+ Download started...
+ No active webpage to share
+ No webpage to create shortcut for
+ Browsing history cleared
+ Cache & cookies cleared
+ All browsing data cleared
+ Delete Browsing Data
+ Already in Shortcuts
+ Cannot bookmark empty page
+ Zoom reset to 100%
+ No active page to share
+ Find in page
+ Browsing history tracking enabled
+ Opened in new tab
+ Link copied to clipboard
+ All browsing history cleared
+ Today\'s history cleared
+ Delete Selected
+ Clear Browsing Data
+ File not found on storage
+ No app found to open this file
+ No active page to bookmark
+ Bookmark Added!
+ PIN Lock Disabled
+ Updating ad blocker filters...
+ Gothwad Browser is already your default browser
+ Select \'Browser app\' -> choose \'Gothwad Browser\'
+ Set Gothwad Browser as default browser in App Settings
+ Could not open system settings
+ Already in Bookmarks
+ No manual shortcuts to remove
+ System Time
+ System Calendar
+ Network & Speed Monitor
+ Storage & Device Info
+ Select Shortcut to Remove
+ Delete Shortcut
+ Web browser for Android TV, tablets and phones.\n\nVersion %1$s\n\nAd-free browsing for the remote, the keyboard and your fingers.
+ Open project page
+ Current time: %1$s\nTimezone: %2$s (%3$s)\nThe clock on the home screen updates by itself.
+ Date: %1$s\n%2$s\nYear: %3$d (leap year: %4$s)
+ Status: %1$s\n\nLive speed\n• Download: %2$s\n• Upload: %3$s\n\nTraffic since boot\n• Received: %4$s\n• Sent: %5$s\n\nMeasured with the system network counters, so nothing extra is downloaded.
+ Storage\n• Free: %1$s\n• Used: %2$s (%3$d%%)\n• Total: %4$s\n\nDevice\n• Model: %5$s\n• Android %6$s (API %7$d)\n• CPU cores: %8$d
+ Time
+ Local time
+ Date
+ Calendar
+ Day %1$d • Week %2$d
+ Offline
+ No network
+ No internet
+ Wi-Fi connected
+ Ethernet connected
+ Mobile data
+ Online
+ Remove %1$s from your shortcuts?
+ Shortcut created: %1$s
+ Cannot open this file: %1$s
+ %1$s is now the default search engine
+ Added to shortcuts: %1$s
+ Searching for: %1$s
+ %1$s: %2$d%%
+ %1$s: 100%% (default)
+ Screen orientation: portrait
+ Screen orientation: auto-rotate
+ Screen orientation: landscape
+ Desktop
+ Mobile
+ %1$s is now blocked
+ %1$s is now allowed
+ Added to bookmarks: %1$s
+ Delete all browsing history, downloaded-file records and cookies?
+
+ - Deleted %1$d item
+ - Deleted %1$d items
+
+
+ - Remove %1$d history entry?
+ - Remove %1$d history entries?
+
+
+ - %1$d input device found
+ - %1$d input devices found
+
+ Connection is not private
+ Shortcut added: %1$s
+ Shortcut removed: %1$s
+ Delete all saved browsing history? This cannot be undone.
+ Address: %1$s\nConnection: %2$s\n\nCookies are enabled and scripts can run, like any other browser.
+ Encrypted (HTTPS)
+ Not encrypted (HTTP)
+ Secure connection
+ Page info
+ Keyboard + mouse
+ Mouse
+ Keyboard
+ Gamepad
+ Input device
+ ID %1$d · %2$s
+ Block
+ No external keyboard or mouse detected.\nNavigation uses the TV remote.
+
+ - %1$d input device connected. Allow or block it below.
+ - %1$d input devices connected. Allow or block each one below.
+
+ Site Title (e.g. JioCinema)
+ URL (https://...)
+ Search bookmarks
+ Search settings
+ Search bookmarks...
+ Search history...
+ Search open and recently closed tabs...
+ Speak to search or type URL…
+ Search your tabs
+ https://easylist.to/easylist/easylist.txt
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 00000000..18ae6105
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+ github.com
+ objects.githubusercontent.com
+ easylist.to
+ gothwad.com
+
+
diff --git a/docs/AUDIT-2026-10-14.md b/docs/AUDIT-2026-10-14.md
new file mode 100644
index 00000000..8aebd7e9
--- /dev/null
+++ b/docs/AUDIT-2026-10-14.md
@@ -0,0 +1,299 @@
+# Gothwad Browser — full audit + fix plan (2026-10-14)
+
+Read this together with `AGENTS.md` (mission, hard rules, incident log). This file is the
+*honest* state of the app: what is actually broken, what is only half-built, what the README
+claims that the code cannot back up yet, and what has already been fixed in this pass.
+
+Everything below was found by reading the code in this repo (130 Kotlin files, ~24.7k lines,
+plus layouts/values/manifest). No issue was filed on GitHub at the time of writing
+(`GothwadTech/browser` has 0 open issues), so this document is the issue tracker for now.
+
+> ⚠️ **Verification limit:** this sandbox has no JDK and no Android SDK, so **nothing here was
+> compiled**. Every change in this pass is small, self-contained and follows patterns already used
+> in the repo, but the first real build on a device is still required. Use §6 checklist.
+
+---
+
+## 0. The one thing to fix before anything else: the licence story
+
+Three statements in the project contradict each other, and one of them is legally dangerous:
+
+| Where | Claim |
+|---|---|
+| `README.md` | "Copyright © Gothwad Tech. All Rights Reserved. **NO OPEN SOURCE LICENSE IS GRANTED**" |
+| `values/strings.xml` → `open_source_notice` / `gothwad_tech_desc` | "**100% Open Source** Software · Zero Server Dependencies" |
+| repo root | no `LICENSE` file exists at all, so the only terms a reader finds are the README's |
+| About screen (`Settings → About`) | same "open source" text |
+
+At the same time the code still carries fingerprints of the upstream project it is built on:
+
+* `Config.HOME_PAGE_URL = "https://tvbrowser.phlox.dev/appcontent/home/"` (in `app/common/…/Config.kt`)
+* `Config.TV_BRO_UA_PREFIX`, `package …tvbrowser…` style class layout, `webengine/webview/*`,
+ `widgets/cursor/*` structure (upstream **TV Bro** by Fedir Tsapana, GPL-3.0-or-later).
+
+This does **not** make the 3–4 months of work unreal — the phone layout, sidebar system, native
+home, TV notepad, clipboard manager, D-pad cursor, etc. are genuinely new work on top. But GPL
+work cannot be re-released as "all rights reserved, no licence": redistribution of a GPL-derived
+app requires the GPL terms (source availability under GPL-3, retained upstream copyright notice,
+no additional restrictions). Two practical consequences:
+
+1. **Google Play / JioStore risk** — a DMCA or a policy rejection on a paid listing is much worse
+ than fixing the notice now.
+2. `REQUEST_INSTALL_PACKAGES` + `QUERY_ALL_PACKAGES` + "download APK" flows are also Play-sensitive
+ (see §3.9 — `QUERY_ALL_PACKAGES` was removed in this pass).
+
+**Action needed from you (I cannot decide this):** either (a) relicense the repo as GPL-3.0 with
+upstream credit, keep the "Gothwad" branding and app-store listings, or (b) if you really want
+proprietary, the app has to be rebuilt from scratch without the upstream code. Pick one and I will
+do the mechanical cleanup (README + About + headers + `LICENSE` file + attribution NOTICE).
+
+---
+
+## 1. What "Chrome-like on phone" means here, and what was wrong
+
+Chrome on Android is: **one address pill on top** (site-security icon · title/URL · mic) and a
+**bottom toolbar** (back · home · new tab · tabs-with-count · ⋮ menu). Google-account / Lens /
+"sign in" surfaces are *not* part of the requirement.
+
+**Before this pass** (see `MainActivityUI.updateToolbarLayoutForOrientation()`, old version):
+in portrait the code simply hid 5 buttons and crammed **Home, omnibox, +, tabs-badge, ⋮** into a
+single 52dp row — ~400dp of widgets in 360dp of screen. The omnibox (`layout_weight=1`) collapsed
+to a few dp wide, so the URL became unreadable and the mic/lock icons overlapped. Meanwhile
+`llBottomPanel` (the bottom toolbar!) was already declared in `activity_main.xml` as a
+**`0dp`-tall, permanently GONE `ConstraintLayout`** — the Chrome bottom bar existed as dead XML
+and nothing was ever drawn in it. `hideMenuOverlay(hideBottomButtons: Boolean)` took a parameter it
+never read, and `toggleMenu()` never toggled anything (it re-showed the header, so the "toggle
+toolbar" keyboard shortcut in `ShortcutMgr.kt:206` was a no-op).
+
+**Fixed in this pass (§4.1 for the exact diff summary):**
+
+* real bottom toolbar in `activity_main.xml` (`ibBottomBack`, `ibBottomHome`, `ibBottomNewTab`,
+ `flBottomTabsSwitcher` + `tvBottomTabCountBadge`, `ibBottomMenu`), 56dp, elevated, drawn over the
+ page; the WebView container / home view / miniature overlay now use
+ `layout_above="@+id/llBottomPanel"`.
+* `isCompactChromeLayout()` (not-TV **and** `smallestScreenWidthDp < 600`) drives the two layouts.
+ A 7–8" tablet is *not* treated as a phone, so TV/tablet keep the full D-pad header — that's the
+ "PC view" the remote needs.
+* top row in phone mode = omnibox only; `llTopTabBar` stays hidden on phones (it is a TV/desktop
+ affordance).
+* `updateBackForwardButtons()` also drives the bottom Back button, `updateTabCountBadge()` also
+ drives the bottom badge, `focusDefaultNavigationElement()`/`showHomeScreen()`/`switchToTab()`
+ focus the *visible* control via `requestHeaderFocus()` (a `requestFocus()` on a GONE button used
+ to silently drop focus and freeze D-pad/keyboard navigation).
+* `toggleMenu()` really toggles on TV/tablet, and `hideMenuOverlay()` keeps the phone bottom bar
+ persistent (Chrome does the same while the address bar is not being edited).
+
+Deliberate deviations from stock Chrome (documented, not accidental): no Google account chip, no
+Lens, no "Search with Google" branding, no swipe-to-refresh, header does not auto-collapse on
+scroll (the fixed header is an explicit AGENTS.md decision, `onScrollChange` says
+"Header is permanently fixed at the top").
+
+---
+
+## 2. Findings — P0 (visible breakage / risk)
+
+1. **Settings text was white on white in a light theme.** `view_settings_main.xml` had
+ `android:textColor="#FFFFFF"` ×31 and `#38BDF8` ×25 on cards whose background
+ (`google_card_bg`) is `#FFFFFF` in day / `#1B1B1B` at night → every label invisible in light mode,
+ plus `SettingsRemoteSection.kt:158 setTextColor(Color.WHITE)` for the input-device list.
+ *Fixed:* all settings labels now use `google_text_primary` / `google_text_secondary` /
+ `google_accent`.
+2. **Half the app is hard-coded dark, so a light theme looks broken.** Sidebars/dialogs use raw hex
+ (`#141822`, `#1E293B`, `#0F172A`) while `bg_tv_left_sidebar` etc. are already theme-aware — so
+ the same screen mixes light and dark surfaces. *Not yet fixed*: needs one new semantic palette
+ (`surface_elevated`, `on_surface`, `on_surface_muted`) applied across ~14 layouts. See §5 W2.
+3. **Search overlay (the Chrome "edit URL" screen) had a `+` icon on the *close* button** and a
+ Google Lens button that just re-ran voice search. Backgrounds/text were pinned to
+ `#131314`/`#E3E3E3`/`#9AA0A6` (dark-only) and the hint said "Search Google or type URL".
+ *Fixed:* real back-arrow, Lens removed, theme-aware colours, `@string/search_or_type_url`,
+ focus handed back to the page on close, suggestion list uses precise RecyclerView updates.
+4. **Find-in-page was a stub.** `ChromeMenuPopup.showFindInPageDialog()` called
+ `webView.findAllAsync(query)` and nothing else: no match count, no next/previous, no
+ `clearMatches()` (highlights stayed), no `FindListener`. *Fixed:* `FindInPageDialog.kt` —
+ debounced incremental search, "n of m" counter, prev/next, `IME_ACTION_SEARCH`/Enter,
+ listener+matches cleaned up on dismiss, D-pad-focusable buttons.
+5. **"Translate this page" was dead.** It built `https://translate.google.com/translate?sl=auto&tl=en&u=…`,
+ an endpoint Google shut down years ago → every user landed on a "feature moved" page.
+ *Fixed:* `PageTranslate.kt` uses the `*.translate.goog` page proxy Chrome itself uses,
+ `_x_tr_pto=wapp` (so **no Google toolbar/account UI** is injected), language picker (24 languages,
+ remembered in `Config.translateTargetLanguage`), and a "Show original" toggle.
+6. **Safe Browsing was disabled in two places** — `WebViewEx` `safeBrowsingEnabled = false` **and**
+ `AndroidManifest` `android.webkit.WebView.EnableSafeBrowsing=false` — while the README sells
+ "the securest browser". Also `mixedContentMode = COMPATIBILITY_MODE` and `allowFileAccess = true`.
+ *Partially fixed:* safe browsing now on by default with a real Settings switch
+ (`Settings → Web Engine & Rendering → Safe Browsing protection`); mixed content / file access
+ still to review (§5 W3) because `file://` loading is used by the built-in file manager.
+7. **`QUERY_ALL_PACKAGES` (Play-restricted) was requested for no reason** — every package lookup in
+ the app resolves `VIEW` / speech / install intents, which `` already covers.
+ *Fixed:* permission removed, explicit `` added; plus a `network_security_config.xml`
+ that pins the app's *own* endpoints (update checks, EasyList, gothwad.com) to TLS while leaving
+ plain `http://` web browsing possible.
+8. **Incognito activity could not survive a keyboard plug-in.** `.activity.IncognitoModeMainActivity`
+ declared `configChanges="orientation|screenSize|screenLayout"` only — no `keyboard|keyboardHidden|
+ navigation|uiMode|density|…`. On TV, connecting a USB keyboard (or a system dark-mode change)
+ recreated the activity and destroyed the private session. *Fixed:* same configChanges as the main
+ activity.
+
+## 3. Findings — P1 (quality / perf / consistency)
+
+9. **`onTrimMemory()` cleared the whole favicon cache on every backgrounding.** Condition was
+ `level >= TRIM_MEMORY_RUNNING_LOW (10) || level >= TRIM_MEMORY_MODERATE (60)` — `TRIM_MEMORY_UI_HIDDEN`
+ is 20, so it always matched → favicons re-decoded on every return to the app. *Fixed* (now only on
+ `RUNNING_CRITICAL`/`BACKGROUND`/`MODERATE`/`COMPLETE`).
+10. **Connectivity via a deprecated broadcast.** `onResume` registered `android.net.conn.CONNECTIVITY_CHANGE`
+ (deprecated at API 28, unreliable/delayed on many TV boxes) and read `activeNetworkInfo`
+ (removed-ish since 29) → after Wi-Fi reconnect the WebView could keep thinking it was offline.
+ *Fixed:* `registerDefaultNetworkCallback` + `Utils.isNetworkConnected()`, unregistered in `onDestroy`.
+11. **`clearCache()` leaked a WebView.** `override suspend fun clearCache(ctx) { WebView(ctx).clearCache(true) }`
+ — never destroyed, and a `WebView` constructor is not valid off the main thread (the caller
+ coroutine could hop dispatchers). *Fixed:* `withContext(Dispatchers.Main)` + `try/finally destroy()`.
+12. **`offscreenPreRaster = true` for every live tab.** Chromium allocates an extra painted buffer per
+ tab; with `maxLiveTabs = 6` that's exactly the memory a 1–2 GB box cannot spare. *Fixed:* on only
+ while the tab is attached (`onAttachedToWindow`/`onDetachedFromWindow`).
+13. **Home-screen dashboard burned CPU on the UI thread.** `NativeHomeView` ticked every 1000ms on a
+ main-thread `Handler` and inside that tick did `ActivityManager.getMemoryInfo()` (IPC) +
+ `StatFs` (syscall) + `TrafficStats` + `notifyItemRangeChanged`. *Fixed:* sampling moved to
+ `Dispatchers.Default`, storage only every 5th tick, ticks skipped when not visible or when the
+ previous sample is still running, scope cancelled on detach.
+ *Still to do:* the "⚡ RAM Boosted" card calls `System.gc()` twice and toasts a "freed" number
+ (`NativeHomeView.kt:435-444`). That is snake oil — Android forbids advertising GC as a
+ performance feature and Chrome has no such thing. Recommendation: replace the card with a real
+ "Memory & tabs" panel (live RSS, per-tab WebView count, a working "Discard background tabs"
+ action = `TabsModel.onTrimMemory()`). Tracked as §5 W4.
+14. **Desktop mode is global, not per tab.** `WebViewEx.isDesktopModeEnabled()` returns
+ `config.isDesktopMode()`; `PageZoomController` and `TabsModel` therefore apply the desktop
+ viewport/UA of *one* tab to *all* tabs, and the menu checkbox ("Desktop site") flips every tab.
+ Chrome's is per-tab — and `HostConfig` already exists in the DB, so the plumbing is half there.
+ *Not fixed in this pass* (touches `WebTabState`, `TabsModel`, menu, zoom controller): §5 W5.
+15. **`Home` button destroys the tab.** `showHomeScreen()` sets `currentTab.url = HOME_URL_ALIAS`,
+ `clearHistory()` and loads `about:blank`, then fakes "forward" via `lastUrlBeforeHome`. Pressing
+ Home on a form-filled page loses the page *and* its back-history — Chrome's home button does
+ neither. *Not fixed* (the `url` field is read in ~8 places; needs a `isHomeOverlayShown` flag
+ instead): §5 W6.
+16. **README/roadmap claims the code can't back yet** (this is what makes it look "incomplete"):
+ * "< 10 MB APK": no measurement in the repo; `largeHeap="true"`, Roboto-era assets, 4 mipmaps ×
+ xxxhdpi PNGs and the whole adblock lib are in there. Also `ic_google_lens.xml` is now unused →
+ delete it with the other orphans (`ivMiniatures`, `llMiniaturePlaceholder`, `progressBarGeneric`
+ if unused, `TopTabsAdapter`'s duplicate new-tab id `ibTopNewTab` which is 0dp and gone).
+ * "10+ languages with responsive 10-foot UI": `values/strings.xml` has 241 strings, every
+ translation has ~160 → ~80 keys English-only, and **everything added since then** (settings
+ categories, Chrome menu, sidebars, search overlay) is hard-coded English in Kotlin/XML.
+ * "AdBlocker: Brave AdBlockClient engine": real (`libs.ad.block`) ✔ — keep.
+ * "100+ real devices tested": there is no test harness in the repo (one `ExampleUnitTest.kt`,
+ one `FaviconExtractorTest.kt`) and no CI job that runs anything but assemble
+ (`.github/workflows/build_apk.yml`).
+ * Store links: `github.com/Gothwad/gothwad-browser` (README) vs `github.com/gothwadtech/tvbrowser`
+ (About screen) vs the real remote `github.com/GothwadTech/browser`; `gothwad.com` vs
+ `gothwadtech.com`; support e-mail `pawangothwad@gmail.com` vs `gothwadtech@gmail.com`; the
+ Play/JioStore badges point at listings that don't exist for `com.gothwad.browser` yet.
+17. **AGENTS.md incident #17 is still open:** 16 `notifyDataSetChanged()` calls in 10 adapters
+ (History, Downloads ×2, Favorites ×2, Tabs sidebar, tab switcher, shortcuts, search overlay ✔ now
+ fixed). Rule 5 of AGENTS.md says they must go.
+
+## 4. Fixed in this pass (files touched)
+
+### 4.1 Code
+| File | Change |
+|---|---|
+| `res/layout/activity_main.xml` | real Chrome bottom toolbar; `layout_above` for web container / home / miniatures; removed the dead 0dp `ConstraintLayout` |
+| `MainActivityUI.kt` | `isCompactChromeLayout()`, `isBottomToolbarVisible()`, `requestHeaderFocus()`; fixed `showMenuOverlay` / `hideMenuOverlay` / `toggleMenu`; rewrote `updateToolbarLayoutForOrientation()`; bottom-bar click wiring + focus/touch listeners |
+| `MainActivity.kt` | NetworkCallback instead of CONNECTIVITY broadcast; `onTrimMemory` level check; layout-aware `focusDefaultNavigationElement()` |
+| `MainActivityNavigation.kt` | bottom Back state in `updateBackForwardButtons()`; focus via `requestHeaderFocus()` (3 call sites) |
+| `MainActivityTabs.kt` | both tab-count badges updated |
+| `MainActivityDpad.kt` | `getHeaderFocusableViews()` includes the bottom toolbar → D-pad cycles the visible row on phones/tablets |
+| `view/search/SearchOverlayView.kt` + `view_chrome_search_screen.xml` | back-arrow icon, no fake Lens, theme colours, i18n hints, focus restore, precise list updates |
+| `dialogs/FindInPageDialog.kt` (new) | Chrome-style find bar on the native `FindListener`/`findNext`/`clearMatches` API |
+| `dialogs/PageTranslate.kt` (new) | `*.translate.goog` proxy, language picker, "Show original" |
+| `dialogs/ChromeMenuPopup.kt` | uses the two helpers above; reading-mode toast reflects the actual state; JS no longer force-paints a dark palette; hardcoded English → string resources |
+| `webengine/webview/WebViewEx.kt` | Safe Browsing from `Config` (default on); `offscreenPreRaster` only while attached |
+| `webengine/webview/WebViewWebEngine.kt` | `clearCache` main-thread + `destroy()` (leak) |
+| `settings/SettingsEngineSection.kt`, `settings/MainSettingsView.kt` | new `initSafeBrowsingUI()` + long-press description |
+| `settings/SettingsRemoteSection.kt` | device-list label follows theme |
+| `res/layout/view_settings_main.xml` (+ shortcuts/version) | 87 hard-coded colours → theme-aware; Safe Browsing row added |
+| `view/home/NativeHomeView.kt` | stats sampling off the UI thread, throttled, lifecycle-safe |
+| `common/…/Config.kt` | `safeBrowsingEnabled` (default true), `translateTargetLanguage` |
+| `AndroidManifest.xml` + `res/xml/network_security_config.xml` | incognito `configChanges`; `QUERY_ALL_PACKAGES` removed; broader ``; SafeBrowsing meta `true`; TLS pinned for first-party endpoints |
+| `res/values/strings.xml` | 31 new keys for the strings that were hard-coded |
+
+### 4.2 Not changed on purpose
+* No CSS/JS is injected for zoom/dark mode (AGENTS rule 1) — the reading-mode style only adjusts
+ measure/leading, and the viewport trick in `PageZoomController` stays as it is.
+* No input event is converted or special-cased per device (rules 2, 3).
+* No `runBlocking`, no new `notifyDataSetChanged`, no device/OEM branches were introduced (verified
+ by grep before finishing, per AGENTS §6).
+
+---
+
+## 5. Next work packages (in the order I recommend)
+> **Update, same day:** W2, W4, W5, W6, W8 and W9 are done, W1/W3/W7/W10 are partly done and W11 is still blocked on the owner. See **§7** at the bottom for the exact state of each package and what remains.
+
+
+
+| # | Package | Why it matters to you | Est. |
+|---|---|---|---|
+| W1 | **Phone UI polish to "exactly Chrome"**: omnibox shows *page title* when scrolled-to-top like Chrome (currently the raw URL), long-press ⋮ → "Customize", pull-to-refresh, address-bar suggestion chips (search + URL + history mixed like Chrome's), bottom bar hidden in fullscreen, incognito badge in the bottom bar | this is the 100%-Chrome ask | 2–3 days |
+| W2 | **One colour system for the whole app**: add `surface_elevated`/`on_surface`/`on_surface_muted`/`outline` in `values` + `values-night`, replace every raw hex in the 14 sidebar/dialog/activity layouts, then let `Config.Theme` drive them (9 themes → 2 today). Delete ~200 orphan dimens/colors | kills "inconsistent UI" permanently | 2 days |
+| W3 | **Security review of the web layer**: `allowFileAccess=false` + explicit `FileProvider`/`content://` path for the file manager, `MIXED_CONTENT_NEVER_ALLOW` default with a per-site override in `HostConfig`, `WebViewAssetLoader` for `internal://` pages, delete `generic_injects.js` bits that are not needed | "securest" claim must be true | 1–2 days |
+| W4 | **Replace the RAM-Boost gimmick** with a real Memory panel + working "Discard background tabs" | unprofessional-work complaint | 1 day |
+| W5 | **Per-tab desktop mode & zoom** (store `desktopMode` per `WebTabState`/`HostConfig`, make `isDesktopModeEnabled()` tab-aware, menu item applies to the current tab only, tab switcher shows a desktop badge) | the "PC view on TV" ask, properly | 2–3 days |
+| W6 | **TV mode = the PC view, for real**: when `Utils.isTV()` and desktop mode is on, force `desktopWebPageZoomPercent` default 100 with a 1280–1920 CSS viewport, show `llTopTabBar`, hide all phone-only affordances, add a "TV input mode" segmented switch (Remote ⇄ Keyboard/Mouse) at the **top of Settings** (that's the "PC tab" you remember: today `showTopTabBar` is buried in *Display & UI Scale* and "Keyboard & Mouse" is a separate category) | your TV requirement | 1–2 days |
+| W7 | **Low-end hardening pass** (1 GB RAM / slow eMMC): `maxLiveTabs` auto = 3 under 2 GB (`ActivityManager.isLowRamDevice`), thumbnails only for the 4 most recent tabs, EasyList parsed once + mmap'd, `largeHeap` removed once memory is bounded, boot-time trace with `adb shell am start -W` | "minimum hardware pe bhi acha chale" | 2–3 days |
+| W8 | **i18n close-out**: extract every hard-coded string (menu, settings categories, sidebars, dialogs), `values-*/` back-fill for the ~80 missing keys, and add a CI check (`newLocaleDraft` gradle task) so it can't drift again | 10+ languages claim | 2 days |
+| W9 | **Kill incident #17** (16 × `notifyDataSetChanged`) + shared `DiffUtil` helper for the sidebar popups | AGENTS rule 5 | 1 day |
+| W10 | **Test/CI floor**: Robolectric tests for `PageZoomController.computeViewportContent`, `ShortcutMgr` modifier matching, `TabsModel` LRU, `PageTranslate.buildProxyUrl`; a `release.yml` job that records APK size per ABI so "<10 MB" is a measured number, not a badge | stops regressions like #13–#16 in AGENTS §5 | 2–3 days |
+| W11 | **Licence/attribution cleanup** (see §0) + honest README rewrite: remove claims that aren't true yet, keep the ones that are | publishing risk | 0.5 day |
+
+---
+
+## 6. Verification checklist for this pass (run on a real phone + a real TV box)
+
+Phone (portrait):
+* [ ] Top row = pill only; bottom row = ← ⌂ + ▣(count) ⋮. Nothing overlaps at 360dp or at 600% font scale.
+* [ ] Tabs badge count matches the tab grid; ← greys out on the home screen; back gesture still works.
+* [ ] Landscape rotation & folding a 2-in-1 → layout switches to the full header, no empty bottom bar.
+* [ ] Light theme **and** dark theme: settings labels readable; search overlay readable; ⋮ menu readable.
+* [ ] Type a URL → suggestions appear; `Enter` navigates; `←` closes and the page regains focus.
+* [ ] ⋮ → Find in page: counter updates while typing, ↑/↓ move matches, closing removes highlights, `Esc`/back closes.
+* [ ] ⋮ → Translate this page → pick language → page loads translated **without** Google's banner;
+ ⋮ → Translate again on that page → "Show original" returns to the exact source URL.
+* [ ] Downloads still work (notification permission path), file upload dialog still works (`` change).
+* [ ] Safe Browsing switch: with it ON, `testsafebrowsing.appspot.com` blocks the sample malware URL.
+
+TV (Android TV / Fire TV / 1–2 GB box):
+* [ ] D-pad: header ←/→ still cycles all controls, ↓ enters the page, ↑ at the very top returns to the header.
+* [ ] "Toggle toolbar" shortcut (Ctrl+\` or whatever is bound in Settings → Shortcuts) now actually hides/shows the header.
+* [ ] Top tab bar ON → tab bar + header, OFF → header only; no stray 28dp gap in phone mode.
+* [ ] Home screen: no jank while cards update (compare `adb shell dumpsys gfxinfo` before/after), clock keeps ticking, cards stop when you leave home.
+* [ ] USB keyboard plug/unplug in an incognito window → **no** restart of the private tabs.
+* [ ] 8 tabs open, switch fast, no `onRenderProcessGone`; `adb shell dumpsys meminfo com.gothwad.browser` should show lower PSS than before on background tabs.
+* [ ] Airplane-mode ON → OFF: page reloads without the app thinking it is still offline.
+
+Build:
+* [ ] `./gradlew :app:assembleDebug` in Android Studio (this sandbox cannot compile), then
+ `./gradlew :app:lintRelease` — watch for the viewBinding id names
+ (`ibBottomBack`, `ibBottomHome`, `ibBottomNewTab`, `flBottomTabsSwitcher`,
+ `tvBottomTabCountBadge`, `llSafeBrowsing`, `scSafeBrowsing`).
+
+
+---
+
+## 7. Work-package status after the fix pass (later on 2026-10-14)
+
+Commits: `086370d` (P0/P1 pass) → `bb3b832` (batches 1–3: app lock, DiffUtil, palette, notifications, per-tab desktop mode) → `2f64e1e` (batches 4–6: injected-JS rewrite, Home/Back model, input modes, memory panel, focus polish) → `58b9245` (batch 7: i18n) → `540c3ca` (batch 8: theme/colour consistency) → `e6ac3bc` (batch 9: low-end perf) → batch 10 (tests + quality gate + CI).
+
+| W | State | What actually landed | What is left |
+|---|---|---|---|
+| W1 | 🟡 partial | Bottom toolbar is a real Chrome row (back · home · new tab · tabs · menu) with the omnibox as a pill above it; `toggleMenu()`/`hideMenuOverlay()` now actually move the bar; fullscreen hides both; tab-switcher buttons match the tabs-bar height (48dp); D-pad focus only targets visible controls | Omnibox showing the page title while scrolled to top, suggestion chips, long-press "Customize" sheet, incognito badge in the bottom bar |
+| W2 | ✅ done | `app_*` semantic palette in `values` + `values-night`; every raw hex in layouts, drawables and Kotlin routed through it (~240 replacements); focus rings made visible (accent stroke instead of fill-on-fill) | Orphan `dimens`/`colors` cleanup; `Config.Theme` still maps 9 names onto 2 behaviours |
+| W3 | 🟡 partial | `safeBrowsingEnabled` follows `Config` (default on); `allowFileAccess` kept **on deliberately** (the home screen loads `file:///android_asset/home/index.html` and the file manager opens local docs) with both `allowFileAccessFromFileURLs`/`allowUniversalAccessFromFileURLs` pinned **false**; `network_security_config` pins first-party hosts to TLS; the plaintext-PIN/app-lock hole closed | `WebViewAssetLoader` for `internal://` (then `allowFileAccess=false`), mixed content is still `MIXED_CONTENT_COMPATIBILITY_MODE` (matches Chrome) — a per-site override in `HostConfig` is not built |
+| W4 | ✅ done | `performRamBoost()` deleted; real memory panel (device RAM, heap, live page views vs cap) with the two actions that actually work; rule 14 now forbids the gimmick | — |
+| W5 | ✅ done | `WebTabState.desktopMode: Boolean?` + Room v22 migration, `setDesktopModeOverride`/`desktopModeOverride` so UA and zoom follow the *displayed* tab, single `DesktopMode.kt` used by all three menus, desktop badge in the tab strip | — |
+| W6 | ✅ done | `Config.InputMode` (AUTO / TV remote / keyboard+mouse / touch) with a segmented switch at the **top of Settings**; writes only existing native flags; re-applied on resume and on device plug/unplug; TV defaults to the desktop view | — |
+| W7 | 🟡 partial | `Config.applyMemoryAwareDefaults()` (live-tab cap from `MemoryInfo.totalMem`, default only — a user preference always wins), thumbnail cap 480px RGB_565 and only for the resumed tab, `setRenderPriority` deleted (no-op since API 21), snapshot writes gated to tabs with a live engine | `largeHeap` still set; EasyList parse is cached but not mmap'd; no boot trace |
+| W8 | ✅ done (drift-proof) | ~300 layout strings + 44 Kotlin strings + all dialog titles/toasts extracted into `values/strings.xml` (560+ entries), `plurals` for counts, emoji stripped, `SettingsDialog`/`openMainCategory` take `@StringRes`, settings search matches the real row text | `values-*/` back-fill for the new keys (the ~80 missing translations predates this pass) |
+| W9 | ✅ done | `utils/ListDiff.kt` + DiffUtil in the favourites/downloads/tabs sidebars and the tab grid; AGENTS §5 #17 closed (the four remaining calls are `BaseAdapter`/`ArrayAdapter` on `ListView`, exempt by design); gate rule enforces it | — |
+| W10 | 🟡 partial | `scripts/quality-gate.sh` (13 mechanical checks, all passing) + `.github/workflows/quality.yml` (gate, unit tests, compile, lint, APK build path untouched); first JVM unit tests: `ConfigRulesTest` pins the live-tab cap, input-mode resolution, zoom clamping, desktop-UA detection and privacy defaults | Robolectric tests for `PageZoomController`/`ShortcutMgr`/`TabsModel`/`PageTranslate`; APK-size-per-ABI recording in `release.yml` |
+| W11 | 🟡 blocked on owner | README About-screen URL/e-mail corrected to the real remote, no e-mail shown in-app | The licence contradiction in §0 is a legal decision, not a code change: either publish under GPL-3.0-or-later with TV Bro attribution, or re-derive the affected modules |
+
+**Verification status (updated after the first real CI run).** The code was authored in an environment with no JDK and no Android SDK, so it went through CI before it went anywhere else: `.github/workflows/quality.yml` now reports **green** — `:app:compileDebugKotlin` + `:app:common:compileDebugKotlin`, `:app:common:testDebugUnitTest` (the 11 new `ConfigRulesTest` cases) and `:app:lintDebug` all pass. The compile earned its keep: it caught 6 defects the static checks could not see — `ActivityManager.MemoryInfo` vs `isLowRamDevice()`, three string resources with an unescaped apostrophe, one with a Kotlin `\u2026` escape, a `return@withContext` label left behind by a moved guard, two missing extension imports, and two JVM property/setter signature clashes. **Two of them were already on `main`** (`` in `version_and_updates`, both the default and `zh-rTW` — `:app:mergeDebugResources` fails on it), which is what a build gate on every PR is for.
+Still owed before this is called production-ready: a real phone (portrait 360dp) + real TV-box run of the §6 checklist in `AGENTS.md`, and an APK-size measurement to back the README number.
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755
diff --git a/scripts/ci-summarize-gradle.sh b/scripts/ci-summarize-gradle.sh
new file mode 100755
index 00000000..2b3c90e6
--- /dev/null
+++ b/scripts/ci-summarize-gradle.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Writes the parts of a failed Gradle run that actually explain the failure into the GitHub job
+# summary. Exists because the raw log lives behind an endpoint a restricted network cannot always
+# reach (`gh run view --log` fails with EOF), and because a red X with no message is not reviewable.
+#
+# Usage: scripts/ci-summarize-gradle.sh [logfile] (default: gradle.log)
+set -uo pipefail
+
+log="${1:-gradle.log}"
+out="${GITHUB_STEP_SUMMARY:-/dev/stdout}"
+
+{
+ echo "### Gradle failure summary"
+ echo
+
+ if [[ ! -s "$log" ]]; then
+ echo "_No Gradle output was captured (\`$log\` missing or empty) — the step failed before piping to it._"
+ else
+ echo '**Compiler errors (Kotlin)**'
+ echo '```'
+ grep -E '^e: ' "$log" | head -60 || true
+ echo '```'
+
+ echo '**Resource compiler errors (aapt2)**'
+ echo '```'
+ grep -E ' error: |error: failed' "$log" | head -40 || true
+ echo '```'
+
+ echo '**What went wrong**'
+ echo '```'
+ awk '/^FAILURE: Build (failed|completion failed)/{p=1} p{print} /Get more help/{p=0}' "$log" | head -60 || true
+ echo '```'
+
+ echo '**Failed tasks**'
+ echo '```'
+ grep -E '^> Task .*FAILED$' "$log" | head -20 || true
+ echo '```'
+
+ reports=$(find . -path '*/build/test-results/*' -name '*.xml' 2>/dev/null | head -20)
+ if [[ -n "$reports" ]]; then
+ echo '**Failing tests**'
+ echo '```'
+ while read -r f; do
+ grep -oE '>"$out"
+
+# A job step's $GITHUB_STEP_SUMMARY is not exposed by the REST API, and the raw log is often
+# unreachable from a restricted network — but *annotations* are readable at
+# GET /repos/{owner}/{repo}/check-runs/{id}/annotations. So the same digest is also emitted as an
+# ::error:: workflow command, which is what a reviewer (or an agent) can actually fetch.
+escape_gh() {
+ printf '%s\n' "$1" \
+ | sed -e 's/%/%25/g' -e 's/:/%3A/g' -e 's/,/%2C/g' -e 's/\r$//' \
+ | awk 'NF { out = out ? out "%0A" $0 : $0 } END { printf "%s", out }'
+}
+
+if [[ -s "$log" ]]; then
+ total=$(grep -cE '^e: ' "$log" || true)
+ digest=$(
+ {
+ [ "$total" -gt 12 ] 2>/dev/null && echo "($total Kotlin compiler errors in total; first 12 below)"
+ grep -E '^e: ' "$log" | head -12
+ grep -E ' error: |^[[:space:]]*error:' "$log" | head -20
+ awk '/^FAILURE: Build (failed|completion failed)/{p=1} p{print} /Get more help/{p=0}' "$log" | head -14
+ grep -E '^> Task .*FAILED$' "$log" | head -6
+ } | head -32
+ )
+ if [[ -n "$digest" ]]; then
+ echo "::error title=Gradle failure::$(escape_gh "${digest:0:2000}")"
+ fi
+fi
diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh
new file mode 100755
index 00000000..03c9ff8b
--- /dev/null
+++ b/scripts/quality-gate.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+# Mechanical checks for the rules in AGENTS.md that a reviewer cannot be trusted to remember.
+# Everything here is a grep, so it runs in milliseconds and needs no Android SDK - which is exactly
+# why the expensive build steps do not have to catch these regressions first.
+#
+# A line may be exempted by ending it with: quality-gate: allow
+set -uo pipefail
+
+cd "$(dirname "$0")/.." || exit 2
+
+SRC=(app/src/main/java app/common/src/main/java)
+RES=(app/src/main/res)
+fail=0
+
+report() {
+ local name="$1"; shift
+ local hits="$1"
+ if [[ -n "$hits" ]]; then
+ echo "FAIL: $name"
+ echo "$hits" | sed 's/^/ /'
+ fail=1
+ else
+ echo "ok: $name"
+ fi
+}
+
+# strip // comments and allow-marked lines before matching, so explanatory comments never trip a gate
+scan() {
+ grep -rnE "$1" "${@:2}" --include='*.kt' --include='*.java' 2>/dev/null \
+ | grep -v 'quality-gate: allow' \
+ | grep -vE '^[^:]+:[0-9]+:[[:space:]]*(\*|//)' \
+ || true
+}
+
+scan_res() {
+ grep -rnE "$1" "${@:2}" --include='*.xml' 2>/dev/null | grep -v 'quality-gate: allow' || true
+}
+
+echo "== AGENTS.md mechanical gates =="
+
+# rule 6 / §6 checklist: no blocking calls on the UI thread
+report "no runBlocking()" "$(scan 'runBlocking[[:space:]]*\(' "${SRC[@]}")"
+report "no Thread.sleep()" "$(scan 'Thread[[:space:]]*\.[[:space:]]*sleep' "${SRC[@]}")"
+report "no SharedPreferences .commit()" "$(scan '\.commit\(\)' "${SRC[@]}")"
+report "no System.gc() (there is no such thing as a RAM booster)" "$(scan 'System[[:space:]]*\.[[:space:]]*gc\(\)' "${SRC[@]}")"
+
+# rule 5: RecyclerView adapters must not blindly rebind
+rv_offenders=""
+while IFS= read -r f; do
+ body=$(grep -vE '^[[:space:]]*(\*|//)' "$f" | grep -v 'quality-gate: allow')
+ if grep -q 'notifyDataSetChanged()' <<<"$body"; then
+ rv_offenders+="$f: notifyDataSetChanged in a RecyclerView adapter"$'\n'
+ fi
+done < <(grep -rl 'RecyclerView.Adapter' "${SRC[@]}" --include='*.kt' 2>/dev/null)
+report "no notifyDataSetChanged() in RecyclerView adapters" "$(sed '/^$/d' <<<"$rv_offenders")"
+
+# rule 14: no OEM / device-model specific branches in code
+report "no Build.MANUFACTURER / MODEL branching" "$(scan 'Build\.(MANUFACTURER|MODEL|BRAND|DEVICE)[[:space:]]*(==|\.equals|\.contains)' "${SRC[@]}")"
+
+# style: logging must go through android.util.Log
+report "no println/stdout" "$(scan 'println[[:space:]]*\(' "${SRC[@]}")"
+
+# i18n: user-visible text must be a resource
+toast_lits=$(scan 'Toast\.makeText\([A-Za-z_.]+, *"' "${SRC[@]}")
+title_lits=$(scan '\.set(Title|Message)\( *"' "${SRC[@]}")
+report "no hard-coded toast text" "$toast_lits"
+report "no hard-coded dialog text" "$title_lits"
+xml_lits=$(scan_res 'android:(text|hint|contentDescription)="[A-Za-z]' "${RES[@]}/layout")
+report "no hard-coded layout text/contentDescription" "$xml_lits"
+
+# theming: colours come from the palette, never from a literal
+layout_hex=$(scan_res 'android:(textColor|textColorHint|background|backgroundTint)="#[0-9A-Fa-f]{6}"' "${RES[@]}/layout")
+drawable_hex=$(scan_res '<(solid|stroke)[^>]*/?>' "${RES[@]}/drawable" | grep -E 'android:color="#[0-9A-Fa-f]{6}"')
+kotlin_hex=$(scan '0xFF[0-9A-Fa-f]{6}' "${SRC[@]}")
+report "no hard-coded colours in layouts" "$layout_hex"
+report "no hard-coded colours in drawables" "$drawable_hex"
+report "no hard-coded colours in Kotlin" "$kotlin_hex"
+
+# resource integrity: a bulk string/colour refactor must not leave a dangling @string/ behind
+STR_RES=$(cat app/src/main/res/values/strings.xml app/common/src/main/res/values/strings.xml 2>/dev/null \
+ | grep -oE '<(string|plurals) name="[^"]+"' | sed 's/.*name="//; s/"//; s/ *$//' | sort -u)
+USED_STR=$(grep -rhoE '@string/[a-zA-Z0-9_]+|(android\.)?R\.string\.[a-zA-Z0-9_]+|R\.plurals\.[a-zA-Z0-9_]+' \
+ app/src/main app/common/src/main --include='*.xml' --include='*.kt' 2>/dev/null \
+ | grep -v '^android\.R\.string' | sed 's|@string/||; s|R\.string\.||; s|R\.plurals\.||' | sort -u)
+missing=$(comm -23 <(printf '%s\n' "$USED_STR") <(printf '%s\n' "$STR_RES"))
+report "no dangling string references" "$missing"
+
+# colour integrity, same reason
+COL_RES=$(cat app/src/main/res/values/colors.xml app/src/main/res/values-night/colors.xml app/common/src/main/res/values/colors.xml 2>/dev/null \
+ | grep -oE '/dev/null | sed 's/\.xml$//')
+USED_COL=$(grep -rhoE '@color/[a-zA-Z0-9_]+|(android\.)?R\.color\.[a-zA-Z0-9_]+' app/src/main app/common/src/main \
+ --include='*.xml' --include='*.kt' 2>/dev/null | grep -v '^android\.R\.color' | sed 's|@color/||; s|R\.color\.||' | sort -u)
+missing_col=$(comm -23 <(printf '%s\n' "$USED_COL") <(printf '%s\n' "$COL_RES" | sort -u))
+report "no dangling colour references" "$missing_col"
+
+# aapt2 rejects an unescaped apostrophe and a \uXXXX escape in a string resource (a resource is
+# not a Kotlin literal). Both came out of scripted extraction, so the script checks the script.
+esc=$(python3 - <<'EOF' 2>/dev/null || true
+import glob, re
+files = sorted(set(glob.glob('app/src/main/res/values*/*.xml') + glob.glob('app/common/src/main/res/values*/*.xml')))
+BS = chr(92)
+for p in files:
+ for i, line in enumerate(open(p, encoding='utf-8'), 1):
+ m = re.match(r'\s*<(?:string|item)\b[^>]*>(.*?)(?:string|item)>\s*$', line)
+ if not m:
+ continue
+ v = m.group(1)
+ bad = []
+ if re.search(BS + '[uU][0-9a-fA-F]', v):
+ bad.append('unicode escape (a resource is not a Kotlin literal)')
+ quoted = v.strip().startswith('"') and v.strip().endswith('"') and v.strip().count('"') == 2
+ if not quoted:
+ for q in [j for j, c in enumerate(v) if c == chr(39)]:
+ k = 0
+ while q - 1 - k >= 0 and v[q - 1 - k] == BS:
+ k += 1
+ if k % 2 == 0: # 0 = unescaped, 2 = escaped backslash then a live quote
+ bad.append('apostrophe needs a single backslash: %r' % v[max(0, q - 6):q + 2])
+ break
+ if bad:
+ print('%s:%d: %s' % (p, i, '; '.join(bad)))
+EOF
+)
+report "string resources escape apostrophes and avoid unicode escapes" "$esc"
+
+# CDATA inside a is a hard aapt2 failure ("Can not extract resource from ParsedResource"),
+# so escaped entities are the only legal way to carry & or < in a resource.
+cdata=$(grep -rn 'CDATA' app/src/main/res/values* app/common/src/main/res/values* 2>/dev/null || true)
+report "no CDATA in resource values" "$cdata"
+
+# user-visible text must stay emoji-free (translatable, and readable on every TV font)
+emoji=$(grep -rnP '"[^"]*[\x{2600}-\x{27BF}]|"[^"]*\xf0\x9f' app/src/main/res/values/strings.xml 2>/dev/null || true)
+report "no emoji in user-visible strings" "$emoji"
+
+# CI must not be the thing that silently stops running: a workflow that fails to parse shows up as
+# a red X with no jobs and no log, which everyone learns to ignore.
+if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then
+ yml=$(python3 - <<'EOF'
+import glob, io, yaml
+for f in sorted(glob.glob(".github/workflows/*.yml")):
+ try:
+ d = yaml.safe_load(io.open(f, encoding="utf-8"))
+ jobs = (d or {}).get("jobs") or {}
+ assert jobs, "no jobs"
+ for name, job in jobs.items():
+ assert "runs-on" in job and job.get("steps"), f"job {name} malformed"
+ for step in job["steps"]:
+ assert isinstance(step, dict), f"job {name}: step is not a mapping: {step!r}"
+ assert bool(step.get("uses")) != bool(step.get("run")), f"job {name}/{step.get('name')}: needs exactly one of uses/run"
+ except Exception as e:
+ print(f"{f}: {type(e).__name__}: {e}")
+EOF
+)
+ report "workflow files parse and define jobs" "$yml"
+fi
+
+echo
+if [[ $fail -ne 0 ]]; then
+ cat <<'EOF'
+Quality gate failed. Fix the findings above (see AGENTS.md for why each rule exists).
+If a finding is genuinely intentional, end that line with: quality-gate: allow
+EOF
+ exit 1
+fi
+echo "Quality gate passed."