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* -[![Platform](https://img.shields.io/badge/Platforms-Android%20TV%20%7C%20Tablets%20%7C%20Linux%20%7C%20Windows%20%7C%20macOS-4CAF50?style=for-the-badge&logo=android)](https://github.com/Gothwad/gothwad-browser) +[![Platform](https://img.shields.io/badge/Platforms-Android%20TV%20%7C%20Tablets%20%7C%20Linux%20%7C%20Windows%20%7C%20macOS-4CAF50?style=for-the-badge&logo=android)](https://github.com/GothwadTech/browser) [![Engine](https://img.shields.io/badge/Engine-Native%20Chromium%20WebView-4285F4?style=for-the-badge&logo=google-chrome)](https://developer.android.com/develop/ui/views/layout/webapps/webview) [![Language](https://img.shields.io/badge/Language-Kotlin-7F52FF?style=for-the-badge&logo=kotlin)](https://kotlinlang.org) [![Target Size](https://img.shields.io/badge/APK%20Footprint-%3C%2010%20MB-FF5722?style=for-the-badge)](#-engineering-journey--vision) @@ -17,13 +17,23 @@ [![Get on Google Play](https://img.shields.io/badge/Google_Play-Get%20it%20on%20Play%20Store-00875A?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.gothwad.browser) [![Get on JioStore](https://img.shields.io/badge/JioStore-Available%20on%20Jio%20Set--Top%20Box-0A2885?style=for-the-badge&logo=reliance&logoColor=white)](https://www.jio.com/fiber/services/set-top-box) [![Official Website](https://img.shields.io/badge/Official_Website-Visit%20gothwad.com-FF6F00?style=for-the-badge&logo=google-chrome&logoColor=white)](https://gothwad.com) -[![Direct Download](https://img.shields.io/badge/GitHub-Download%20Latest%20Release-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/Gothwad/gothwad-browser/releases) +[![Direct Download](https://img.shields.io/badge/GitHub-Download%20Latest%20Release-181717?style=for-the-badge&logo=github&logoColor=white)](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" />