From f21fff5c7dad7f25e4a618c3875dada063eb6bfc Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 13:00:16 +0530 Subject: [PATCH 01/20] fix: align custom tabs with Fenix for prompts, downloads, and media --- .../customtabs/ExternalAppBrowserActivity.kt | 14 ++- .../customtabs/ExternalAppBrowserFragment.kt | 95 ++++++++++++++----- 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/prirai/android/nira/customtabs/ExternalAppBrowserActivity.kt b/app/src/main/java/com/prirai/android/nira/customtabs/ExternalAppBrowserActivity.kt index 2d8c386..d540019 100644 --- a/app/src/main/java/com/prirai/android/nira/customtabs/ExternalAppBrowserActivity.kt +++ b/app/src/main/java/com/prirai/android/nira/customtabs/ExternalAppBrowserActivity.kt @@ -93,24 +93,22 @@ open class ExternalAppBrowserActivity : BrowserActivity() { private var hasCalledOnCreate = false override fun onDestroy() { - // Clean up custom tab session before calling super if (isFinishing) { - // When this activity finishes, the process is staying around and the session still - // exists then remove it now to free all its resources. Once this activity is finished - // then there's no way to get back to it other than relaunching it. val tabId = getExternalTabId() val customTab = tabId?.let { components.store.state.findCustomTab(it) } if (tabId != null && customTab != null) { - components.tabsUseCases.removeTab(tabId) + customTab.mediaSessionState?.controller?.pause() + components.customTabsUseCases.remove(tabId) } - - // Remove from recents when custom tab is closed finishAndRemoveTask() } - super.onDestroy() } + override fun getIntentSessionId(intent: SafeIntent): String? { + return com.prirai.android.nira.ext.getIntentSessionId(intent) + } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun getExternalTabId(): String? { return getIntentSessionId(SafeIntent(intent)) diff --git a/app/src/main/java/com/prirai/android/nira/customtabs/ExternalAppBrowserFragment.kt b/app/src/main/java/com/prirai/android/nira/customtabs/ExternalAppBrowserFragment.kt index 32bff01..801e2ec 100644 --- a/app/src/main/java/com/prirai/android/nira/customtabs/ExternalAppBrowserFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/customtabs/ExternalAppBrowserFragment.kt @@ -16,28 +16,35 @@ import androidx.fragment.app.Fragment import com.prirai.android.nira.BrowserActivity import com.prirai.android.nira.R import com.prirai.android.nira.databinding.FragmentBrowserBinding +import com.prirai.android.nira.downloads.DownloadService import com.prirai.android.nira.ext.components import com.prirai.android.nira.integration.ContextMenuIntegration import com.prirai.android.nira.integration.FindInPageIntegration +import com.prirai.android.nira.preferences.UserPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.mapNotNull import mozilla.components.browser.state.selector.findCustomTab import mozilla.components.browser.state.state.SessionState import mozilla.components.concept.engine.EngineView import mozilla.components.feature.customtabs.CustomTabWindowFeature +import mozilla.components.feature.downloads.DownloadsFeature +import mozilla.components.feature.downloads.manager.FetchDownloadManager +import mozilla.components.feature.media.fullscreen.MediaSessionFullscreenFeature import mozilla.components.feature.prompts.PromptFeature import mozilla.components.feature.session.SessionFeature import mozilla.components.feature.session.SwipeRefreshFeature +import mozilla.components.feature.sitepermissions.SitePermissionsFeature import mozilla.components.lib.state.ext.flowScoped -import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import mozilla.components.support.base.feature.ActivityResultHandler import mozilla.components.support.base.feature.UserInteractionHandler +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import kotlin.math.pow /** * Fragment for external app browsing with custom header UI but using Mozilla's core features. * Combines custom branding/UI with Mozilla's intent processing and session management. */ -class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { +class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler { private var _binding: FragmentBrowserBinding? = null private val binding get() = _binding!! @@ -49,8 +56,10 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { private val swipeRefreshFeature = ViewBoundFeatureWrapper() private val promptsFeature = ViewBoundFeatureWrapper() private val findInPageIntegration = ViewBoundFeatureWrapper() - private val downloadsFeature = ViewBoundFeatureWrapper() + private val downloadsFeature = ViewBoundFeatureWrapper() private val contextMenuIntegration = ViewBoundFeatureWrapper() + private val sitePermissionsFeature = ViewBoundFeatureWrapper() + private val fullScreenMediaSessionFeature = ViewBoundFeatureWrapper() private var customTabHeader: LinearLayout? = null private var customTabTitle: TextView? = null @@ -140,13 +149,13 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { view = binding.root ) - // Prompt feature - required for select dropdowns, dialogs, and file pickers promptsFeature.set( feature = PromptFeature( fragment = this, store = components.store, + customTabId = sessionId, tabsUseCases = components.tabsUseCases, - fragmentManager = parentFragmentManager, + fragmentManager = childFragmentManager, fileUploadsDirCleaner = components.fileUploadsDirCleaner, onNeedToRequestPermissions = { permissions -> requestPermissions(permissions, REQUEST_CODE_PROMPT_PERMISSIONS) @@ -155,6 +164,32 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { owner = this, view = binding.root ) + + sitePermissionsFeature.set( + feature = SitePermissionsFeature( + context = requireContext(), + storage = components.permissionStorage, + fragmentManager = childFragmentManager, + sessionId = sessionId, + onNeedToRequestPermissions = { permissions -> + requestPermissions(permissions, REQUEST_CODE_APP_PERMISSIONS) + }, + onShouldShowRequestPermissionRationale = { shouldShowRequestPermissionRationale(it) }, + store = components.store + ), + owner = this, + view = binding.root + ) + + fullScreenMediaSessionFeature.set( + feature = MediaSessionFullscreenFeature( + requireActivity(), + components.store, + sessionId + ), + owner = this, + view = binding.root + ) // Custom tab window feature - handles opening links in same window customTabWindowFeature.set( @@ -196,15 +231,14 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { view = binding.root ) - // Downloads feature - CRITICAL for handling downloads downloadsFeature.set( - feature = mozilla.components.feature.downloads.DownloadsFeature( + feature = DownloadsFeature( requireContext().applicationContext, store = components.store, useCases = components.downloadsUseCases, fragmentManager = childFragmentManager, - shouldForwardToThirdParties = { - com.prirai.android.nira.preferences.UserPreferences(requireContext()).promptExternalDownloader + shouldForwardToThirdParties = { + UserPreferences(requireContext()).promptExternalDownloader }, downloadFileUtils = mozilla.components.support.utils.DefaultDownloadFileUtils( context = requireContext().applicationContext, @@ -214,10 +248,10 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { ).path } ), - downloadManager = mozilla.components.feature.downloads.manager.FetchDownloadManager( + downloadManager = FetchDownloadManager( requireContext().applicationContext, components.store, - com.prirai.android.nira.downloads.DownloadService::class, + DownloadService::class, notificationsDelegate = components.notificationsDelegate ), tabId = sessionId, @@ -320,10 +354,7 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { // Set up close button customTabCloseButton?.setOnClickListener { - customTabSessionId?.let { sessionId -> - requireContext().components.customTabsUseCases.remove(sessionId) - } - requireActivity().finish() + closeCustomTab() } // Set up menu button @@ -476,7 +507,7 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { mozilla.components.browser.state.action.TabListAction.AddTabAction(newTab, select = true) ) - // Remove custom tab + pauseCustomTabMedia(sessionId) requireContext().components.customTabsUseCases.remove(sessionId) // Launch main browser @@ -491,21 +522,37 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { } } + private fun pauseCustomTabMedia(sessionId: String) { + val tab = requireContext().components.store.state.findCustomTab(sessionId) ?: return + tab.mediaSessionState?.controller?.pause() + } + + private fun closeCustomTab() { + val sessionId = customTabSessionId + if (sessionId != null) { + pauseCustomTabMedia(sessionId) + requireContext().components.customTabsUseCases.remove(sessionId) + } + requireActivity().finish() + } + override fun onBackPressed(): Boolean { - // Check if find in page is open if (findInPageIntegration.onBackPressed()) { return true } - - // Check if we can go back in history + if (promptsFeature.onBackPressed()) { + return true + } if (sessionFeature.onBackPressed()) { return true } - - // If nothing handled it, finish activity - requireActivity().finish() + closeCustomTab() return true } + + override fun onActivityResult(requestCode: Int, data: Intent?, resultCode: Int): Boolean { + return promptsFeature.onActivityResult(requestCode, data, resultCode) + } override fun onRequestPermissionsResult( requestCode: Int, @@ -519,6 +566,9 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { REQUEST_CODE_PROMPT_PERMISSIONS -> { promptsFeature.get()?.onPermissionsResult(permissions, grantResults) } + REQUEST_CODE_APP_PERMISSIONS -> { + sitePermissionsFeature.get()?.onPermissionsResult(permissions, grantResults) + } } } @@ -531,5 +581,6 @@ class ExternalAppBrowserFragment : Fragment(), UserInteractionHandler { companion object { private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 1 private const val REQUEST_CODE_PROMPT_PERMISSIONS = 2 + private const val REQUEST_CODE_APP_PERMISSIONS = 3 } } From db8aa3d2bb96bb70d41c370d5bf95487c5305163 Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 15:10:19 +0530 Subject: [PATCH 02/20] feat: persist recently closed tabs in the browser store Add RecentlyClosedTabsStorage and RecentlyClosedMiddleware so closed normal tabs stay available for homepage restore. --- .../com/prirai/android/nira/components/Components.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/com/prirai/android/nira/components/Components.kt b/app/src/main/java/com/prirai/android/nira/components/Components.kt index 2331a98..23c6ab7 100644 --- a/app/src/main/java/com/prirai/android/nira/components/Components.kt +++ b/app/src/main/java/com/prirai/android/nira/components/Components.kt @@ -67,6 +67,8 @@ import mozilla.components.feature.pwa.WebAppInterceptor import mozilla.components.feature.pwa.WebAppShortcutManager import mozilla.components.feature.pwa.WebAppUseCases import mozilla.components.feature.readerview.ReaderViewMiddleware +import mozilla.components.feature.recentlyclosed.RecentlyClosedMiddleware +import mozilla.components.feature.recentlyclosed.RecentlyClosedTabsStorage import mozilla.components.feature.search.SearchUseCases import mozilla.components.feature.search.middleware.SearchMiddleware import mozilla.components.feature.search.region.RegionMiddleware @@ -91,6 +93,7 @@ import java.util.concurrent.TimeUnit private const val DAY_IN_MINUTES = 24 * 60L +private const val RECENTLY_CLOSED_MAX = 10 /** No-op [CrashReporting] for components that require one but Nira has no crash service. */ private val noOpCrashReporter = object : CrashReporting { @@ -224,6 +227,10 @@ open class Components(private val applicationContext: Context) { val sessionStorage by lazy { SessionStorage(applicationContext, engine) } + val recentlyClosedTabsStorage by lazy { + RecentlyClosedTabsStorage(applicationContext, engine, noOpCrashReporter) + } + val permissionStorage by lazy { GeckoSitePermissionsStorage(runtime, OnDiskSitePermissionsStorage(applicationContext)) } val thumbnailStorage by lazy { ThumbnailStorage(applicationContext) } @@ -270,6 +277,10 @@ open class Components(private val applicationContext: Context) { RecordingDevicesMiddleware(applicationContext, notificationsDelegate), PromptMiddleware(), LastAccessMiddleware(), + RecentlyClosedMiddleware( + lazy { recentlyClosedTabsStorage }, + RECENTLY_CLOSED_MAX, + ), SaveToPDFMiddleware(applicationContext), com.prirai.android.nira.browser.tabgroups.TabGroupMiddleware(tabGroupManager), profileMiddleware, // Use the exposed instance From e33559b1f59773397323be603664a55f6f7b4a4a Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 15:10:31 +0530 Subject: [PATCH 03/20] feat: wire homepage recents and replace bookmark grid Show Jump Back In from open tabs plus Places history, restore recently closed tabs, and replace the bookmark grid with History and Bookmarks buttons. --- .../prirai/android/nira/BrowserFragment.kt | 65 +++-- .../nira/browser/home/ComposeHomeFragment.kt | 70 ++++-- .../nira/browser/home/compose/HomeScreen.kt | 235 ++++++++---------- .../browser/home/compose/HomeViewModel.kt | 54 +++- 4 files changed, 252 insertions(+), 172 deletions(-) diff --git a/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt b/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt index 42eba63..b53b7d5 100644 --- a/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt @@ -5,10 +5,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope +import com.prirai.android.nira.browser.home.compose.jumpBackInItems +import com.prirai.android.nira.browser.home.compose.recentlyClosedItems import com.prirai.android.nira.browser.toolbar.ToolbarGestureHandler import com.prirai.android.nira.browser.toolbar.WebExtensionToolbarFeature import com.prirai.android.nira.components.toolbar.ToolbarMenu @@ -1078,7 +1081,8 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { val factory = com.prirai.android.nira.browser.home.compose.HomeViewModelFactory( bookmarkManager = com.prirai.android.nira.browser.bookmark.repository.BookmarkManager.getInstance(requireContext()), - shortcutDao = database.shortcutDao() + shortcutDao = database.shortcutDao(), + historyStorage = requireContext().components.historyStorage, ) homeViewModel = androidx.lifecycle.ViewModelProvider(this, factory)[com.prirai.android.nira.browser.home.compose.HomeViewModel::class.java] @@ -1111,9 +1115,21 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { val isPrivateMode = selectedTab?.content?.private ?: browsingModeManager.mode.isPrivate val shortcuts by homeViewModel.shortcuts.collectAsState() - val bookmarks by homeViewModel.bookmarks.collectAsState() val showAddDialog by homeViewModel.showAddShortcutDialog.collectAsState() - val isBookmarkExpanded by homeViewModel.isBookmarkSectionExpanded.collectAsState() + val jumpBackInHistory by homeViewModel.jumpBackInHistory.collectAsState() + val recentsKey by store.observeAsComposableState { state -> + Triple( + state.selectedTabId, + state.tabs.map { it.id to it.lastAccess }, + state.closedTabs.map { it.id }, + ) + } + val jumpBackInItems = androidx.compose.runtime.remember(recentsKey, jumpBackInHistory) { + store.state.jumpBackInItems(jumpBackInHistory) + } + val recentlyClosedItems by store.observeAsComposableState { state -> + state.recentlyClosedItems() + } val profileManager = com.prirai.android.nira.browser.profile.ProfileManager.getInstance(requireContext()) @@ -1158,8 +1174,6 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { com.prirai.android.nira.browser.home.compose.HomeScreen( isPrivateMode = isPrivateMode, shortcuts = shortcuts, - bookmarks = bookmarks, - isBookmarkExpanded = isBookmarkExpanded, currentProfile = currentProfile, onProfileClick = {}, // Profile icon is display-only backgroundImageUrl = backgroundImageUrl, @@ -1181,18 +1195,41 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { .show() }, onShortcutAdd = { homeViewModel.showAddShortcutDialog() }, - onBookmarkClick = { bookmark -> - if (bookmark.isFolder) { - val bookmarksBottomSheet = com.prirai.android.nira.browser.bookmark.ui.BookmarksBottomSheetFragment.newInstance( - folderId = bookmark.id.toLongOrNull() ?: -1L + onHistoryClick = { + startActivity( + android.content.Intent( + requireContext(), + com.prirai.android.nira.history.HistoryActivity::class.java ) - bookmarksBottomSheet.show(parentFragmentManager, "BookmarksBottomSheet") + ) + }, + onBookmarksClick = { + com.prirai.android.nira.browser.bookmark.ui.BookmarksBottomSheetFragment + .newInstance() + .show(parentFragmentManager, "BookmarksBottomSheet") + }, + jumpBackInItems = jumpBackInItems, + recentlyClosedItems = recentlyClosedItems.orEmpty(), + onJumpBackInClick = { item -> + val tabId = item.tabId + if (tabId != null) { + components.tabsUseCases.selectTab(tabId) } else { - components.sessionUseCases.loadUrl(bookmark.url) - // URL will change, triggering visibility update automatically + components.sessionUseCases.loadUrl(item.url) + } + }, + onRecentlyClosedClick = { item -> + val tab = store.state.closedTabs.find { it.id == item.id } ?: return@HomeScreen + viewLifecycleOwner.lifecycleScope.launch { + components.tabsUseCases.restore( + tab, + components.recentlyClosedTabsStorage.engineStateStorage(), + ) + store.dispatch( + mozilla.components.browser.state.action.RecentlyClosedAction.RemoveClosedTabAction(tab) + ) } }, - onBookmarkToggle = { homeViewModel.toggleBookmarkSection() }, onSearchClick = { // Open search dialog val sessionId = components.store.state.selectedTabId @@ -1229,12 +1266,10 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { ) if (isHomepage) { - // Show homepage, hide engine view binding.swipeRefresh.visibility = View.GONE binding.engineView.asView().visibility = View.GONE homePageView?.visibility = View.VISIBLE } else { - // Show engine view, hide homepage homePageView?.visibility = View.GONE binding.swipeRefresh.visibility = View.VISIBLE binding.engineView.asView().visibility = View.VISIBLE diff --git a/app/src/main/java/com/prirai/android/nira/browser/home/ComposeHomeFragment.kt b/app/src/main/java/com/prirai/android/nira/browser/home/ComposeHomeFragment.kt index ba9b821..c881402 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/home/ComposeHomeFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/home/ComposeHomeFragment.kt @@ -6,11 +6,13 @@ import android.view.View import android.view.ViewGroup import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.room.Room import androidx.room.migration.Migration @@ -27,6 +29,8 @@ import com.prirai.android.nira.browser.home.compose.HomeScreen import com.prirai.android.nira.browser.home.compose.HomeViewModel import com.prirai.android.nira.browser.home.compose.HomeViewModelFactory import com.prirai.android.nira.browser.home.compose.ProfileInfo +import com.prirai.android.nira.browser.home.compose.jumpBackInItems +import com.prirai.android.nira.browser.home.compose.recentlyClosedItems import com.prirai.android.nira.browser.shortcuts.ShortcutDatabase import com.prirai.android.nira.components.toolbar.BrowserToolbarViewInteractor import com.prirai.android.nira.components.toolbar.ToolbarMenu @@ -39,6 +43,7 @@ import com.prirai.android.nira.ui.theme.NiraTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.privateTabs @@ -90,7 +95,8 @@ class ComposeHomeFragment : Fragment() { val factory = HomeViewModelFactory( bookmarkManager = BookmarkManager.getInstance(requireContext()), - shortcutDao = database.shortcutDao() + shortcutDao = database.shortcutDao(), + historyStorage = requireContext().components.historyStorage, ) viewModel = ViewModelProvider(this, factory)[HomeViewModel::class.java] @@ -137,9 +143,21 @@ class ComposeHomeFragment : Fragment() { val isPrivateMode = selectedTab?.content?.private ?: browsingModeManager.mode.isPrivate val shortcuts by viewModel.shortcuts.collectAsState() - val bookmarks by viewModel.bookmarks.collectAsState() val showAddDialog by viewModel.showAddShortcutDialog.collectAsState() - val isBookmarkExpanded by viewModel.isBookmarkSectionExpanded.collectAsState() + val jumpBackInHistory by viewModel.jumpBackInHistory.collectAsState() + val recentsKey by store.observeAsComposableState { state -> + Triple( + state.selectedTabId, + state.tabs.map { it.id to it.lastAccess }, + state.closedTabs.map { it.id }, + ) + } + val jumpBackInItems = androidx.compose.runtime.remember(recentsKey, jumpBackInHistory) { + store.state.jumpBackInItems(jumpBackInHistory) + } + val recentlyClosedItems by store.observeAsComposableState { state -> + state.recentlyClosedItems() + } if (isPrivateMode) { store.state.privateTabs.size @@ -195,8 +213,6 @@ class ComposeHomeFragment : Fragment() { HomeScreen( isPrivateMode = isPrivateMode, shortcuts = shortcuts, - bookmarks = bookmarks, - isBookmarkExpanded = isBookmarkExpanded, currentProfile = currentProfile, onProfileClick = {}, // Profile icon is now display-only backgroundImageUrl = backgroundImageUrl, @@ -223,23 +239,40 @@ class ComposeHomeFragment : Fragment() { .show() }, onShortcutAdd = { viewModel.showAddShortcutDialog() }, - onBookmarkClick = { bookmark -> - if (bookmark.isFolder) { - val bookmarksBottomSheet = BookmarksBottomSheetFragment.newInstance( - folderId = bookmark.id.toLongOrNull() ?: -1L + onHistoryClick = { + startActivity( + android.content.Intent( + requireContext(), + com.prirai.android.nira.history.HistoryActivity::class.java ) - bookmarksBottomSheet.show(parentFragmentManager, "BookmarksBottomSheet") + ) + }, + onBookmarksClick = { + BookmarksBottomSheetFragment.newInstance() + .show(parentFragmentManager, "BookmarksBottomSheet") + }, + jumpBackInItems = jumpBackInItems, + recentlyClosedItems = recentlyClosedItems.orEmpty(), + onJumpBackInClick = { item -> + val tabId = item.tabId + if (tabId != null) { + components.tabsUseCases.selectTab(tabId) } else { - components.sessionUseCases.loadUrl(bookmark.url) - // Navigate to browser to show the loaded page - try { - findNavController().navigate(R.id.browserFragment) - } catch (e: Exception) { - // Ignore navigation errors - } + components.sessionUseCases.loadUrl(item.url) + } + }, + onRecentlyClosedClick = { item -> + val tab = store.state.closedTabs.find { it.id == item.id } ?: return@HomeScreen + viewLifecycleOwner.lifecycleScope.launch { + components.tabsUseCases.restore( + tab, + components.recentlyClosedTabsStorage.engineStateStorage(), + ) + store.dispatch( + mozilla.components.browser.state.action.RecentlyClosedAction.RemoveClosedTabAction(tab) + ) } }, - onBookmarkToggle = { viewModel.toggleBookmarkSection() }, onSearchClick = { // Pass current selected tab ID for proper context val sessionId = components.store.state.selectedTabId @@ -534,6 +567,7 @@ class ComposeHomeFragment : Fragment() { super.onResume() viewModel.loadShortcuts() viewModel.loadBookmarks() + viewModel.loadJumpBackInHistory() updateToolbarStyling() } diff --git a/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeScreen.kt b/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeScreen.kt index e3ce7c0..e5d3356 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeScreen.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeScreen.kt @@ -29,10 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog @@ -69,6 +66,7 @@ import coil.request.ImageRequest import com.prirai.android.nira.R import com.prirai.android.nira.browser.tabs.compose.FaviconImageFromUrl import com.prirai.android.nira.settings.HomepageChoice +import mozilla.components.browser.state.state.BrowserState data class ProfileInfo( val id: String, @@ -79,28 +77,76 @@ data class ProfileInfo( ) data class JumpBackInItem( + val id: String, val url: String, - val title: String + val title: String, + val tabId: String? = null, ) data class RecentlyClosedItem( + val id: String, val url: String, val title: String ) +private fun String.isHomepageCardUrl(): Boolean { + return isNotBlank() && !startsWith("about:") && !startsWith("chrome:") +} + +fun BrowserState.jumpBackInItems( + historyItems: List = emptyList(), + limit: Int = 4, +): List { + val openTabs = tabs + .filter { tab -> + !tab.content.private && + tab.id != selectedTabId && + tab.content.url.isHomepageCardUrl() + } + .sortedByDescending { it.lastAccess } + .map { tab -> + JumpBackInItem( + id = tab.id, + url = tab.content.url, + title = tab.content.title.ifBlank { tab.content.url }, + tabId = tab.id, + ) + } + + val usedUrls = openTabs.map { it.url }.toMutableSet() + closedTabs.forEach { usedUrls.add(it.url) } + + val fromHistory = historyItems.filter { item -> + item.url.isHomepageCardUrl() && usedUrls.add(item.url) + } + + return (openTabs + fromHistory).take(limit) +} + +fun BrowserState.recentlyClosedItems(limit: Int = 4): List { + return closedTabs + .filter { tab -> !tab.private && tab.url.isHomepageCardUrl() } + .take(limit) + .map { tab -> + RecentlyClosedItem( + id = tab.id, + url = tab.url, + title = tab.title.ifBlank { tab.url } + ) + } +} + @Composable fun HomeScreen( isPrivateMode: Boolean, shortcuts: List, - bookmarks: List, - isBookmarkExpanded: Boolean, currentProfile: ProfileInfo, onProfileClick: () -> Unit, onShortcutClick: (ShortcutItem) -> Unit, onShortcutDelete: (ShortcutItem) -> Unit, onShortcutAdd: () -> Unit, - onBookmarkClick: (BookmarkItem) -> Unit, - onBookmarkToggle: () -> Unit, + onHistoryClick: () -> Unit, + onBookmarksClick: () -> Unit, onSearchClick: () -> Unit, jumpBackInItems: List = emptyList(), recentlyClosedItems: List = emptyList(), @@ -213,13 +259,10 @@ fun HomeScreen( ) } - // Bookmarks section item { - BookmarksSection( - bookmarks = bookmarks, - isExpanded = isBookmarkExpanded, - onBookmarkClick = onBookmarkClick, - onToggle = onBookmarkToggle + LibraryLinksRow( + onHistoryClick = onHistoryClick, + onBookmarksClick = onBookmarksClick, ) } } else if (isPrivateMode && homepageType != HomepageChoice.BLANK_PAGE.ordinal) { @@ -478,140 +521,60 @@ fun ShortcutItem( } @Composable -fun BookmarksSection( - bookmarks: List, - isExpanded: Boolean, - onBookmarkClick: (BookmarkItem) -> Unit, - onToggle: () -> Unit, +fun LibraryLinksRow( + onHistoryClick: () -> Unit, + onBookmarksClick: () -> Unit, modifier: Modifier = Modifier ) { - Column(modifier = modifier) { - // Section header with expand/collapse - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onToggle() } - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Bookmarks", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - - Icon( - imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = if (isExpanded) "Collapse" else "Expand", - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - // Grid - only show when expanded - if (isExpanded) { - Spacer(modifier = Modifier.height(12.dp)) - - if (bookmarks.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 32.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "No bookmarks yet. Add bookmarks while browsing, from the menu or import them in settings.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - textAlign = TextAlign.Center - ) - } - } else { - // Use a FlowRow or simple grid layout for non-scrollable bookmarks - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Group bookmarks into rows of 4 - bookmarks.chunked(4).forEach { rowBookmarks -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - rowBookmarks.forEach { bookmark -> - Box(modifier = Modifier.weight(1f)) { - BookmarkItem( - bookmark = bookmark, - onClick = { onBookmarkClick(bookmark) } - ) - } - } - // Add empty boxes to fill the row if needed - repeat(4 - rowBookmarks.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - } - } - } + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + LibraryLinkButton( + label = "History", + icon = R.drawable.ic_baseline_history, + onClick = onHistoryClick, + modifier = Modifier.weight(1f) + ) + LibraryLinkButton( + label = "Bookmarks", + icon = R.drawable.ic_baseline_bookmark, + onClick = onBookmarksClick, + modifier = Modifier.weight(1f) + ) } } @Composable -fun BookmarkItem( - bookmark: BookmarkItem, +private fun LibraryLinkButton( + label: String, + icon: Int, onClick: () -> Unit, modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .clickable(onClick = onClick) - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + Surface( + modifier = modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) ) { - // Icon for folder or site - Surface( - shape = if (bookmark.isFolder) RoundedCornerShape(12.dp) else CircleShape, - color = if (bookmark.isFolder) - MaterialTheme.colorScheme.tertiaryContainer - else - MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(56.dp) + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - if (bookmark.isFolder) { - Icon( - imageVector = Icons.Default.Folder, - contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size(32.dp) - ) - } else { - // Use FaviconImageFromUrl for loading favicons - FaviconImageFromUrl( - url = bookmark.url, - title = bookmark.title, - size = 32.dp, - modifier = Modifier.size(32.dp) - ) - } - } + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) } - - Text( - text = bookmark.title, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onBackground - ) } } diff --git a/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeViewModel.kt b/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeViewModel.kt index eeae91b..7a16120 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeViewModel.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeViewModel.kt @@ -14,10 +14,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import mozilla.components.concept.storage.HistoryStorage +import mozilla.components.concept.storage.VisitType class HomeViewModel( private val bookmarkManager: BookmarkManager, - private val shortcutDao: ShortcutDao + private val shortcutDao: ShortcutDao, + private val historyStorage: HistoryStorage, ) : ViewModel() { private val _shortcuts = MutableStateFlow>(emptyList()) @@ -31,10 +34,43 @@ class HomeViewModel( private val _isBookmarkSectionExpanded = MutableStateFlow(true) val isBookmarkSectionExpanded: StateFlow = _isBookmarkSectionExpanded.asStateFlow() + + private val _jumpBackInHistory = MutableStateFlow>(emptyList()) + val jumpBackInHistory: StateFlow> = _jumpBackInHistory.asStateFlow() init { loadShortcuts() loadBookmarks() + loadJumpBackInHistory() + } + + fun loadJumpBackInHistory() { + viewModelScope.launch { + try { + val visits = withContext(Dispatchers.IO) { + historyStorage.getVisitsPaginated( + offset = 0, + count = HISTORY_PAGE_SIZE, + excludeTypes = EXCLUDED_VISIT_TYPES, + ) + } + val seen = linkedSetOf() + _jumpBackInHistory.value = visits.mapNotNull { visit -> + val url = visit.url + if (!url.startsWith("http") || !seen.add(url)) { + null + } else { + JumpBackInItem( + id = "history:$url", + url = url, + title = visit.title?.ifBlank { url } ?: url, + ) + } + } + } catch (e: Exception) { + _jumpBackInHistory.value = emptyList() + } + } } fun loadShortcuts() { @@ -146,12 +182,24 @@ class HomeViewModel( class HomeViewModelFactory( private val bookmarkManager: BookmarkManager, - private val shortcutDao: ShortcutDao + private val shortcutDao: ShortcutDao, + private val historyStorage: HistoryStorage, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(HomeViewModel::class.java)) { - return HomeViewModel(bookmarkManager, shortcutDao) as T + return HomeViewModel(bookmarkManager, shortcutDao, historyStorage) as T } throw IllegalArgumentException("Unknown ViewModel class") } } + +private const val HISTORY_PAGE_SIZE = 40L + +private val EXCLUDED_VISIT_TYPES = listOf( + VisitType.DOWNLOAD, + VisitType.REDIRECT_PERMANENT, + VisitType.REDIRECT_TEMPORARY, + VisitType.RELOAD, + VisitType.EMBED, + VisitType.FRAMED_LINK, +) From ca975e6103fbb358699ff7792455700f5aa0b315 Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 15:10:55 +0530 Subject: [PATCH 04/20] feat: add download pause, resume, search, and parallel Range fetches Wire pause/resume and search in the downloads sheet. Large HTTP files use a 4-thread Range downloader and fall back when the server refuses. --- app/src/main/AndroidManifest.xml | 6 + .../nira/downloads/ComposeDownloadsScreen.kt | 92 +++-- .../nira/downloads/DownloadController.kt | 105 ++++++ .../nira/downloads/ParallelDownloadService.kt | 321 ++++++++++++++++++ 4 files changed, 488 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/prirai/android/nira/downloads/DownloadController.kt create mode 100644 app/src/main/java/com/prirai/android/nira/downloads/ParallelDownloadService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 96301da..f5d6fe6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -221,6 +221,12 @@ android:exported="false" /> + + Unit) { val store = context.components.store val downloadsState by store.observeAsComposableState { it.downloads } val downloads = downloadsState ?: emptyMap() + var query by remember { mutableStateOf("") } val activeStatuses = setOf( DownloadState.Status.INITIATED, @@ -86,12 +86,24 @@ fun DownloadsScreen(onDismiss: () -> Unit) { DownloadState.Status.PAUSED, ) + val matchesQuery: (DownloadState) -> Boolean = { download -> + val needle = query.trim() + needle.isEmpty() || + (download.fileName?.contains(needle, ignoreCase = true) == true) || + download.url.contains(needle, ignoreCase = true) + } + val activeDownloads = downloads.values - .filter { it.status in activeStatuses } + .filter { it.status in activeStatuses && matchesQuery(it) } .sortedByDescending { it.createdTime } val completedDownloads = downloads.values - .filter { it.status == DownloadState.Status.COMPLETED || it.status == DownloadState.Status.FAILED || it.status == DownloadState.Status.CANCELLED } + .filter { + (it.status == DownloadState.Status.COMPLETED || + it.status == DownloadState.Status.FAILED || + it.status == DownloadState.Status.CANCELLED) && + matchesQuery(it) + } .sortedByDescending { it.createdTime } Surface( @@ -137,6 +149,26 @@ fun DownloadsScreen(onDismiss: () -> Unit) { ) } + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + singleLine = true, + placeholder = { Text("Search downloads") }, + leadingIcon = { + Icon(imageVector = Icons.Default.Search, contentDescription = null) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { query = "" }) { + Icon(imageVector = Icons.Default.Close, contentDescription = "Clear search") + } + } + } + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) if (downloads.isEmpty()) { @@ -173,12 +205,7 @@ fun DownloadsScreen(onDismiss: () -> Unit) { downloads = activeDownloads, onItemClick = { /* active items don't open */ }, onCancelClick = { dl -> - val intent = Intent(AbstractFetchDownloadService.ACTION_CANCEL).apply { - setPackage(context.packageName) - putExtra(INTENT_EXTRA_DOWNLOAD_ID, dl.id) - } - context.sendBroadcast(intent) - context.components.store.dispatch(DownloadAction.RemoveDownloadAction(dl.id)) + DownloadController.cancel(context, dl) }, onDelete = { id -> context.components.store.dispatch(DownloadAction.RemoveDownloadAction(id)) @@ -198,11 +225,7 @@ fun DownloadsScreen(onDismiss: () -> Unit) { when (dl.status) { DownloadState.Status.COMPLETED -> openDownloadedFile(context, dl) DownloadState.Status.FAILED -> { - val intent = Intent(AbstractFetchDownloadService.ACTION_TRY_AGAIN).apply { - setPackage(context.packageName) - putExtra(INTENT_EXTRA_DOWNLOAD_ID, dl.id) - } - context.sendBroadcast(intent) + DownloadController.start(context, dl) Toast.makeText(context, "Retrying download…", Toast.LENGTH_SHORT).show() } DownloadState.Status.CANCELLED -> { @@ -360,17 +383,7 @@ fun DownloadListItem( Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( - onClick = { - ContextCompat.startForegroundService( - context, - Intent(context, DownloadService::class.java).apply { - putExtra( - android.app.DownloadManager.EXTRA_DOWNLOAD_ID, - download.id, - ) - }, - ) - }, + onClick = { DownloadController.start(context, download) }, modifier = Modifier.weight(1f), ) { Text("Download") @@ -432,8 +445,15 @@ fun DownloadListItem( color = MaterialTheme.colorScheme.onSurfaceVariant ) } + Spacer(Modifier.width(4.dp)) + IconButton(onClick = { DownloadController.pause(context, download.id) }) { + Icon( + imageVector = Icons.Default.Pause, + contentDescription = "Pause", + tint = MaterialTheme.colorScheme.primary, + ) + } if (onCancelClick != null) { - Spacer(Modifier.width(8.dp)) TextButton( onClick = onCancelClick, colors = ButtonDefaults.textButtonColors( @@ -506,14 +526,14 @@ fun DownloadListItem( modifier = Modifier.size(20.dp) ) DownloadState.Status.PAUSED -> { - Icon( - imageVector = Icons.Default.Pause, - contentDescription = "Paused", - tint = Color(0xFFFF9800), - modifier = Modifier.size(20.dp) - ) + IconButton(onClick = { DownloadController.resume(context, download) }) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Resume", + tint = MaterialTheme.colorScheme.primary, + ) + } if (onCancelClick != null) { - Spacer(Modifier.width(4.dp)) TextButton( onClick = onCancelClick, colors = ButtonDefaults.textButtonColors( diff --git a/app/src/main/java/com/prirai/android/nira/downloads/DownloadController.kt b/app/src/main/java/com/prirai/android/nira/downloads/DownloadController.kt new file mode 100644 index 0000000..fe9eed7 --- /dev/null +++ b/app/src/main/java/com/prirai/android/nira/downloads/DownloadController.kt @@ -0,0 +1,105 @@ +package com.prirai.android.nira.downloads + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import mozilla.components.browser.state.action.DownloadAction +import mozilla.components.browser.state.state.content.DownloadState +import mozilla.components.feature.downloads.AbstractFetchDownloadService +import mozilla.components.feature.downloads.INTENT_EXTRA_DOWNLOAD_ID +import com.prirai.android.nira.ext.components + +object DownloadController { + const val PARALLEL_MIN_BYTES = 8L * 1024 * 1024 + const val PARALLEL_THREADS = 4 + + const val ACTION_PAUSE = "com.prirai.android.nira.downloads.PAUSE" + const val ACTION_RESUME = "com.prirai.android.nira.downloads.RESUME" + const val ACTION_CANCEL = "com.prirai.android.nira.downloads.CANCEL" + + fun shouldUseParallel(context: Context, download: DownloadState): Boolean { + val length = download.contentLength ?: 0L + val http = download.url.startsWith("http://") || download.url.startsWith("https://") + return http && ( + length >= PARALLEL_MIN_BYTES || + ParallelDownloadService.hasSidecar(context, download) + ) + } + + fun start(context: Context, download: DownloadState) { + val app = context.applicationContext + if (shouldUseParallel(app, download)) { + startService(app, ParallelDownloadService::class.java, download.id) + } else { + startService(app, DownloadService::class.java, download.id) + } + } + + fun startFallback(context: Context, downloadId: String) { + startService(context.applicationContext, DownloadService::class.java, downloadId) + } + + fun pause(context: Context, downloadId: String) { + val app = context.applicationContext + app.sendBroadcast( + Intent(AbstractFetchDownloadService.ACTION_PAUSE).apply { + setPackage(app.packageName) + putExtra(INTENT_EXTRA_DOWNLOAD_ID, downloadId) + } + ) + app.sendBroadcast( + Intent(ACTION_PAUSE).apply { + setPackage(app.packageName) + putExtra(INTENT_EXTRA_DOWNLOAD_ID, downloadId) + } + ) + } + + fun resume(context: Context, download: DownloadState) { + val app = context.applicationContext + if (shouldUseParallel(app, download)) { + app.sendBroadcast( + Intent(ACTION_RESUME).apply { + setPackage(app.packageName) + putExtra(INTENT_EXTRA_DOWNLOAD_ID, download.id) + } + ) + startService(app, ParallelDownloadService::class.java, download.id) + } else { + app.sendBroadcast( + Intent(AbstractFetchDownloadService.ACTION_RESUME).apply { + setPackage(app.packageName) + putExtra(INTENT_EXTRA_DOWNLOAD_ID, download.id) + } + ) + startService(app, DownloadService::class.java, download.id) + } + } + + fun cancel(context: Context, download: DownloadState) { + val app = context.applicationContext + app.sendBroadcast( + Intent(AbstractFetchDownloadService.ACTION_CANCEL).apply { + setPackage(app.packageName) + putExtra(INTENT_EXTRA_DOWNLOAD_ID, download.id) + } + ) + app.sendBroadcast( + Intent(ACTION_CANCEL).apply { + setPackage(app.packageName) + putExtra(INTENT_EXTRA_DOWNLOAD_ID, download.id) + } + ) + app.components.store.dispatch(DownloadAction.RemoveDownloadAction(download.id)) + } + + private fun startService(context: Context, service: Class<*>, downloadId: String) { + ContextCompat.startForegroundService( + context, + Intent(context, service).apply { + putExtra(android.app.DownloadManager.EXTRA_DOWNLOAD_ID, downloadId) + putExtra(INTENT_EXTRA_DOWNLOAD_ID, downloadId) + } + ) + } +} diff --git a/app/src/main/java/com/prirai/android/nira/downloads/ParallelDownloadService.kt b/app/src/main/java/com/prirai/android/nira/downloads/ParallelDownloadService.kt new file mode 100644 index 0000000..9167f8e --- /dev/null +++ b/app/src/main/java/com/prirai/android/nira/downloads/ParallelDownloadService.kt @@ -0,0 +1,321 @@ +package com.prirai.android.nira.downloads + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.prirai.android.nira.R +import com.prirai.android.nira.ext.components +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import mozilla.components.browser.state.action.DownloadAction +import mozilla.components.browser.state.state.content.DownloadState +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.feature.downloads.INTENT_EXTRA_DOWNLOAD_ID +import mozilla.components.feature.downloads.filewriter.DefaultDownloadFileWriter +import mozilla.components.support.utils.DefaultDownloadFileUtils +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile + +class ParallelDownloadService : Service() { + + private val serviceJob = SupervisorJob() + private val scope = CoroutineScope(serviceJob + Dispatchers.IO) + private val jobs = mutableMapOf() + private var receiverRegistered = false + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val id = intent?.getStringExtra(INTENT_EXTRA_DOWNLOAD_ID) ?: return + when (intent.action) { + DownloadController.ACTION_PAUSE -> pause(id) + DownloadController.ACTION_CANCEL -> cancelDownload(id) + DownloadController.ACTION_RESUME -> { + val download = components.store.state.downloads[id] ?: return + startOrResume(download) + } + } + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + ensureChannel() + startForeground(NOTIFICATION_ID, buildNotification("Downloads")) + if (!receiverRegistered) { + val filter = IntentFilter().apply { + addAction(DownloadController.ACTION_PAUSE) + addAction(DownloadController.ACTION_CANCEL) + addAction(DownloadController.ACTION_RESUME) + } + ContextCompat.registerReceiver(this, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + receiverRegistered = true + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val id = intent?.getStringExtra(INTENT_EXTRA_DOWNLOAD_ID) + ?: intent?.getStringExtra(android.app.DownloadManager.EXTRA_DOWNLOAD_ID) + ?: return START_NOT_STICKY + val download = components.store.state.downloads[id] ?: return START_NOT_STICKY + when (intent?.action) { + DownloadController.ACTION_PAUSE -> pause(id) + DownloadController.ACTION_CANCEL -> cancelDownload(id) + else -> startOrResume(download) + } + return START_STICKY + } + + override fun onDestroy() { + if (receiverRegistered) { + unregisterReceiver(receiver) + receiverRegistered = false + } + serviceJob.cancel() + super.onDestroy() + } + + private fun startOrResume(download: DownloadState) { + if (jobs[download.id]?.isActive == true) return + jobs[download.id] = scope.launch { + try { + runDownload(download) + } catch (_: CancellationException) { + } catch (_: Exception) { + update(download.id) { it.copy(status = DownloadState.Status.FAILED) } + } finally { + jobs.remove(download.id) + if (jobs.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + } + } + + private fun pause(id: String) { + jobs.remove(id)?.cancel() + update(id) { it.copy(status = DownloadState.Status.PAUSED) } + if (jobs.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun cancelDownload(id: String) { + jobs.remove(id)?.cancel() + sidecarDir(id).deleteRecursively() + update(id) { it.copy(status = DownloadState.Status.CANCELLED) } + if (jobs.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private suspend fun runDownload(initial: DownloadState) { + val client = components.client + val probed = probeTotal(initial) + val total = initial.contentLength?.takeIf { it > 0 } ?: probed + if (total == null || total < DownloadController.PARALLEL_MIN_BYTES || probed == null) { + DownloadController.startFallback(this, initial.id) + return + } + + val download = initial.copy( + status = DownloadState.Status.DOWNLOADING, + contentLength = total, + ) + update(download.id) { download } + + val dir = sidecarDir(download.id) + dir.mkdirs() + val threadCount = DownloadController.PARALLEL_THREADS + val chunkSize = total / threadCount + + coroutineScope { + val progress = launch { + while (isActive) { + val copied = partFiles(dir, threadCount).sumOf { it.length() } + update(download.id) { + it.copy( + status = DownloadState.Status.DOWNLOADING, + contentLength = total, + currentBytesCopied = copied.coerceAtMost(total), + ) + } + delay(400) + } + } + try { + (0 until threadCount).map { index -> + async(Dispatchers.IO) { + val start = index * chunkSize + val end = if (index == threadCount - 1) total - 1 else (index + 1) * chunkSize - 1 + downloadChunk(download, start, end, File(dir, "part$index")) + } + }.awaitAll() + } finally { + progress.cancel() + } + } + + assembleAndPublish(download.copy(contentLength = total), dir, threadCount, total) + } + + private suspend fun downloadChunk( + download: DownloadState, + start: Long, + end: Long, + part: File, + ) { + val existing = if (part.exists()) part.length() else 0L + val expected = end - start + 1 + if (existing >= expected) return + val rangeStart = start + existing + val headers = MutableHeaders() + headers.append("Range", "bytes=$rangeStart-$end") + val request = Request( + url = download.url, + headers = headers, + private = download.private, + referrerUrl = download.referrerUrl, + ) + val response = components.client.fetch(request) + if (response.status != 206) { + response.close() + throw IOException("Unexpected status ${response.status}") + } + val job = currentCoroutineContext()[Job] + response.body.useStream { input -> + RandomAccessFile(part, "rw").use { raf -> + raf.seek(existing) + val buffer = ByteArray(64 * 1024) + while (job?.isActive != false) { + val read = input.read(buffer) + if (read == -1) break + raf.write(buffer, 0, read) + } + } + } + if (job?.isActive == false) { + throw CancellationException() + } + } + + private fun assembleAndPublish( + download: DownloadState, + dir: File, + threadCount: Int, + total: Long, + ) { + val assembled = File(dir, "assembled") + assembled.outputStream().use { out -> + partFiles(dir, threadCount).forEach { part -> + part.inputStream().use { it.copyTo(out) } + } + } + val fileUtils = DefaultDownloadFileUtils( + context = applicationContext, + downloadLocation = { download.directoryPath }, + ) + val writer = DefaultDownloadFileWriter(applicationContext, fileUtils) + writer.useFileStream( + download = download, + append = false, + shouldUseScopedStorage = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q, + onUpdateState = { updated -> update(download.id) { updated } }, + ) { out -> + assembled.inputStream().use { it.copyTo(out) } + } + dir.deleteRecursively() + update(download.id) { + it.copy( + status = DownloadState.Status.COMPLETED, + contentLength = total, + currentBytesCopied = total, + ) + } + } + + private fun probeTotal(download: DownloadState): Long? { + return try { + val headers = MutableHeaders() + headers.append("Range", "bytes=0-0") + val request = Request( + url = download.url, + headers = headers, + private = download.private, + referrerUrl = download.referrerUrl, + ) + components.client.fetch(request).use { response -> + if (response.status != 206) return null + parseTotal(response.headers["Content-Range"]) + } + } catch (_: Exception) { + null + } + } + + private fun update(id: String, transform: (DownloadState) -> DownloadState) { + val current = components.store.state.downloads[id] ?: return + components.store.dispatch(DownloadAction.UpdateDownloadAction(transform(current))) + } + + private fun sidecarDir(id: String): File = File(cacheDir, "nira-dl/$id") + + private fun partFiles(dir: File, count: Int): List = + (0 until count).map { File(dir, "part$it") } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_LOW) + ) + } + + private fun buildNotification(text: String): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_baseline_download_24) + .setContentTitle("Downloading") + .setContentText(text) + .setOngoing(true) + .build() + } + + companion object { + private const val CHANNEL_ID = "nira_parallel_downloads" + private const val NOTIFICATION_ID = 7101 + + fun hasSidecar(context: Context, download: DownloadState): Boolean { + return File(context.cacheDir, "nira-dl/${download.id}").let { it.exists() && it.list()?.isNotEmpty() == true } + } + + fun parseTotal(contentRange: String?): Long? { + val total = contentRange?.substringAfterLast('/') ?: return null + return total.toLongOrNull()?.takeIf { it > 0 } + } + } +} From db45cb08138c7e2e71665fe5d71de4764863858e Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 15:11:12 +0530 Subject: [PATCH 05/20] fix: keep SessionFeature on the selected tab after tab switches Ignore normal tab ids in EXTRA_SESSION_ID so EngineView follows selectedTab. Stop swipe navigation from recreating home/browser. --- .../prirai/android/nira/BaseBrowserFragment.kt | 16 +++++++++------- .../browser/toolbar/ToolbarGestureHandler.kt | 9 --------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/prirai/android/nira/BaseBrowserFragment.kt b/app/src/main/java/com/prirai/android/nira/BaseBrowserFragment.kt index a953bc5..2a4be62 100644 --- a/app/src/main/java/com/prirai/android/nira/BaseBrowserFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/BaseBrowserFragment.kt @@ -57,7 +57,6 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withContext import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.selector.findCustomTab -import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab import mozilla.components.browser.state.selector.getNormalOrPrivateTabs @@ -163,9 +162,12 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit container: ViewGroup?, savedInstanceState: Bundle? ): View { - // For custom tabs: read EXTRA_SESSION_ID (used by external apps) - // For normal browsing: customTabSessionId should be null so SessionFeature follows selected tab - customTabSessionId = requireArguments().getString(EXTRA_SESSION_ID) + // Only pin SessionFeature for a real custom tab. A normal tab id in this arg + // freezes EngineView on that session after later selectTab() calls. + val argSessionId = requireArguments().getString(EXTRA_SESSION_ID) + customTabSessionId = argSessionId?.takeIf { + requireContext().components.store.state.findCustomTab(it) != null + } _binding = FragmentBrowserBinding.inflate(inflater, container, false) val view = binding.root @@ -611,7 +613,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit internal fun expandToolbarOnNavigation(store: BrowserStore) { consumeFlow(store) { flow -> flow.mapNotNull { state -> - state.findCustomTabOrSelectedTab(customTabSessionId) + state.findTabOrCustomTabOrSelectedTab(customTabSessionId) } .ifAnyChanged { tab -> arrayOf(tab.content.url, tab.content.loadRequest) @@ -979,7 +981,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit @VisibleForTesting internal fun getCurrentTab(): SessionState? { - return requireContext().components.store.state.findCustomTabOrSelectedTab(customTabSessionId) + return requireContext().components.store.state.findTabOrCustomTabOrSelectedTab(customTabSessionId) } override fun onHomePressed() = pipFeature?.onHomePressed() ?: false @@ -1184,7 +1186,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit // Lifecycle-aware features cleaned up automatically webContentPositionManager?.destroy() webContentPositionManager = null - binding.engineView.setActivityContext(null) + _binding?.engineView?.setActivityContext(null) _browserInteractor = null _unifiedToolbar = null _binding = null diff --git a/app/src/main/java/com/prirai/android/nira/browser/toolbar/ToolbarGestureHandler.kt b/app/src/main/java/com/prirai/android/nira/browser/toolbar/ToolbarGestureHandler.kt index 878fa53..2e8edb9 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/toolbar/ToolbarGestureHandler.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/toolbar/ToolbarGestureHandler.kt @@ -13,7 +13,6 @@ import androidx.core.graphics.contains import androidx.core.graphics.toPoint import androidx.core.view.isVisible import androidx.interpolator.view.animation.LinearOutSlowInInterpolator -import androidx.navigation.findNavController import com.prirai.android.nira.R import com.prirai.android.nira.browser.FakeTab import com.prirai.android.nira.browser.SwipeGestureListener @@ -228,14 +227,6 @@ class ToolbarGestureHandler( doOnEnd { contentLayout.translationX = 0f - val currentTab = store.state.selectedTab ?: return@doOnEnd - if(currentTab.content.url == "about:homepage" && tab.content.url != "about:homepage") { - activity.findNavController(R.id.container).navigate(R.id.browserFragment) - } else if(tab.content.url == "about:homepage" && currentTab.content.url != "about:homepage") { - activity.findNavController(R.id.container).navigate(R.id.homeFragment) - } - - // Mark this as swipe navigation before selecting lruManager.markAsSwipeNavigation(tab.id) selectTabUseCase(tab.id) From 812f84709caf3d04b3f7b119070a3e61f4128f68 Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 15:19:54 +0530 Subject: [PATCH 06/20] fix: keep EngineView attached under the homepage overlay Do not set GeckoView GONE when showing about:homepage. Overlay the homepage ComposeView instead so tab switches keep a live surface. --- .../android/nira/BaseBrowserFragment.kt | 1 + .../prirai/android/nira/BrowserFragment.kt | 25 +++++++++++-------- app/src/main/res/layout/fragment_browser.xml | 1 - 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/prirai/android/nira/BaseBrowserFragment.kt b/app/src/main/java/com/prirai/android/nira/BaseBrowserFragment.kt index 2a4be62..387f781 100644 --- a/app/src/main/java/com/prirai/android/nira/BaseBrowserFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/BaseBrowserFragment.kt @@ -155,6 +155,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit private var _binding: FragmentBrowserBinding? = null protected val binding get() = _binding!! + protected val bindingOrNull: FragmentBrowserBinding? get() = _binding @CallSuper override fun onCreateView( diff --git a/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt b/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt index b53b7d5..77b8312 100644 --- a/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt @@ -326,6 +326,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { flow.mapNotNull { state -> state.selectedTabId } .distinctUntilChanged() .collect { tabId: String -> + if (!isAdded) return@collect val tab = components.store.state.tabs.find { it.id == tabId } tab?.let { updateContentVisibility(it.content.url) @@ -340,6 +341,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { } .distinctUntilChangedBy { it.content.url } .collect { tab -> + if (!isAdded) return@collect updateContentVisibility(tab.content.url) } } @@ -1259,20 +1261,21 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { * Shows homepage ComposeView for "about:homepage", otherwise shows engineView */ private fun updateContentVisibility(url: String) { - val isHomepage = url == "about:homepage" || url.isEmpty() - + val binding = bindingOrNull ?: return + val isHomepage = url == "about:homepage" + val homePageView = binding.root.findViewById( R.id.homePageComposeView ) - - if (isHomepage) { - binding.swipeRefresh.visibility = View.GONE - binding.engineView.asView().visibility = View.GONE - homePageView?.visibility = View.VISIBLE - } else { - homePageView?.visibility = View.GONE - binding.swipeRefresh.visibility = View.VISIBLE - binding.engineView.asView().visibility = View.VISIBLE + + // Keep EngineView attached. GONE detaches Gecko's surface and later selectTab + // can leave the last frame stuck (AC#6664 / bug 1630775). Homepage sits on top. + binding.swipeRefresh.visibility = View.VISIBLE + binding.engineView.asView().visibility = View.VISIBLE + homePageView?.apply { + visibility = if (isHomepage) View.VISIBLE else View.GONE + isClickable = isHomepage + isFocusable = isHomepage } } } diff --git a/app/src/main/res/layout/fragment_browser.xml b/app/src/main/res/layout/fragment_browser.xml index e2b753a..43f8f9f 100644 --- a/app/src/main/res/layout/fragment_browser.xml +++ b/app/src/main/res/layout/fragment_browser.xml @@ -35,7 +35,6 @@ android:id="@+id/engineView" android:layout_width="match_parent" android:layout_height="match_parent" - android:visibility="gone" /> From f2aeb65d6ee11cc4e82ecfe62fb5a21334d2902b Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 16:09:15 +0530 Subject: [PATCH 07/20] feat: load search engines from Remote Settings Wire SearchEngineSelectorConfig so suggest and search URLs stay current after Android Components bumps. --- app/build.gradle | 2 ++ .../android/nira/components/Components.kt | 32 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index a9d1fdd..68751f8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -188,6 +188,8 @@ dependencies { implementation "org.mozilla.components:support-webextensions:$mozComponentsVersion" implementation "org.mozilla.components:support-ktx:$mozComponentsVersion" implementation "org.mozilla.components:support-locale:$mozComponentsVersion" + implementation "org.mozilla.components:support-remotesettings:$mozComponentsVersion" + implementation "org.mozilla.appservices:remotesettings:153.0" // Mozilla UI components implementation "org.mozilla.components:ui-autocomplete:$mozComponentsVersion" diff --git a/app/src/main/java/com/prirai/android/nira/components/Components.kt b/app/src/main/java/com/prirai/android/nira/components/Components.kt index 23c6ab7..ccd890b 100644 --- a/app/src/main/java/com/prirai/android/nira/components/Components.kt +++ b/app/src/main/java/com/prirai/android/nira/components/Components.kt @@ -18,6 +18,7 @@ import com.prirai.android.nira.settings.ThemeChoice import com.prirai.android.nira.share.SaveToPDFMiddleware import com.prirai.android.nira.utils.ClipboardHandler import com.prirai.android.nira.utils.FaviconCache +import com.prirai.android.nira.utils.Utils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -69,9 +70,13 @@ import mozilla.components.feature.pwa.WebAppUseCases import mozilla.components.feature.readerview.ReaderViewMiddleware import mozilla.components.feature.recentlyclosed.RecentlyClosedMiddleware import mozilla.components.feature.recentlyclosed.RecentlyClosedTabsStorage +import mozilla.components.feature.search.SearchApplicationName +import mozilla.components.feature.search.SearchDeviceType +import mozilla.components.feature.search.SearchUpdateChannel import mozilla.components.feature.search.SearchUseCases import mozilla.components.feature.search.middleware.SearchMiddleware import mozilla.components.feature.search.region.RegionMiddleware +import mozilla.components.feature.search.storage.SearchEngineSelectorConfig import mozilla.components.feature.session.HistoryDelegate import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.session.middleware.LastAccessMiddleware @@ -86,6 +91,8 @@ import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider import mozilla.components.service.location.LocationService import mozilla.components.support.base.android.NotificationsDelegate import mozilla.components.support.base.worker.Frequency +import mozilla.appservices.remotesettings.RemoteSettingsServer +import mozilla.components.support.remotesettings.RemoteSettingsService import org.mozilla.geckoview.ContentBlocking import org.mozilla.geckoview.GeckoRuntime import org.mozilla.geckoview.GeckoRuntimeSettings @@ -248,6 +255,15 @@ open class Components(private val applicationContext: Context) { com.prirai.android.nira.browser.profile.ProfileMiddleware(profileManager) } + val remoteSettingsService by lazy { + RemoteSettingsService( + applicationContext, + RemoteSettingsServer.Prod, + channel = "release", + isLargeScreenSize = Utils().isTablet(applicationContext), + ) + } + val store by lazy { BrowserStore( middleware = listOf( @@ -273,7 +289,21 @@ open class Components(private val applicationContext: Context) { applicationContext, LocationService.default() ), - SearchMiddleware(applicationContext), + SearchMiddleware( + applicationContext, + searchEngineSelectorConfig = SearchEngineSelectorConfig( + appName = SearchApplicationName.FIREFOX_ANDROID, + appVersion = com.prirai.android.nira.BuildConfig.VERSION_NAME, + deviceType = if (Utils().isTablet(applicationContext)) { + SearchDeviceType.TABLET + } else { + SearchDeviceType.SMARTPHONE + }, + experiment = "", + updateChannel = SearchUpdateChannel.RELEASE, + service = remoteSettingsService, + ), + ), RecordingDevicesMiddleware(applicationContext, notificationsDelegate), PromptMiddleware(), LastAccessMiddleware(), From d7f500b2b5d7ccdfed82712ca2cf7376d3b155f1 Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 16:10:02 +0530 Subject: [PATCH 08/20] feat: apply search engines live and add a private default Select engines without restart, honor private-mode defaults in search UI, and accept %s in custom search URLs. --- .../prirai/android/nira/BrowserActivity.kt | 59 ++------------ .../android/nira/browser/SearchEngineList.kt | 16 +++- .../nira/browser/SearchEnginePreferences.kt | 60 +++++++++++++++ .../nira/browser/profile/ProfileManager.kt | 5 ++ .../nira/preferences/UserPreferences.kt | 2 + .../nira/search/SearchDialogController.kt | 2 +- .../nira/search/SearchFragmentStore.kt | 11 ++- .../nira/search/awesomebar/AwesomeBarView.kt | 5 +- .../fragment/GeneralSettingsFragment.kt | 77 +++++++++++++------ app/src/main/res/values/strings.xml | 10 ++- app/src/main/res/xml/preferences_general.xml | 7 ++ 11 files changed, 167 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/com/prirai/android/nira/browser/SearchEnginePreferences.kt diff --git a/app/src/main/java/com/prirai/android/nira/BrowserActivity.kt b/app/src/main/java/com/prirai/android/nira/BrowserActivity.kt index 08f3c17..2ceca98 100644 --- a/app/src/main/java/com/prirai/android/nira/BrowserActivity.kt +++ b/app/src/main/java/com/prirai/android/nira/BrowserActivity.kt @@ -26,6 +26,7 @@ import com.prirai.android.nira.browser.BrowsingMode import com.prirai.android.nira.browser.BrowsingModeManager import com.prirai.android.nira.browser.DefaultBrowsingModeManager import com.prirai.android.nira.browser.SearchEngineList +import com.prirai.android.nira.browser.SearchEnginePreferences import com.prirai.android.nira.databinding.ActivityMainBinding import com.prirai.android.nira.ext.alreadyOnDestination import com.prirai.android.nira.ext.components @@ -36,7 +37,6 @@ import com.prirai.android.nira.search.SearchDialogFragmentDirections import com.prirai.android.nira.theme.applyAppTheme import com.prirai.android.nira.utils.Utils import kotlinx.coroutines.launch -import mozilla.components.browser.icons.IconRequest import mozilla.components.browser.state.action.AppLifecycleAction import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.state.SessionState @@ -45,7 +45,6 @@ import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineView import mozilla.components.feature.contextmenu.ext.DefaultSelectionActionDelegate -import mozilla.components.feature.search.ext.createSearchEngine import mozilla.components.support.base.feature.ActivityResultHandler import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.ktx.kotlin.isUrl @@ -149,8 +148,12 @@ open class BrowserActivity : LocaleAwareAppCompatActivity(), ComponentCallbacks2 // OPTIMIZATION: Defer search engine setup to after first frame // This was accessing components.store.state which triggers heavy initialization view.post { - setupSearchEngines() - // Also prefetch publicSuffixList after UI is ready + lifecycleScope.launch { + SearchEnginePreferences.apply(this@BrowserActivity, private = false) + if (UserPreferences(this@BrowserActivity).privateSearchEngineChoice >= 0) { + SearchEnginePreferences.apply(this@BrowserActivity, private = true) + } + } components.publicSuffixList.prefetch() } @@ -570,54 +573,6 @@ open class BrowserActivity : LocaleAwareAppCompatActivity(), ComponentCallbacks2 super.attachBaseContext(base) } - /** - * Setup search engines - extracted to separate method for deferred initialization - */ - private fun setupSearchEngines() { - //TODO: Move to settings page so app restart no longer required - //TODO: Differentiate between using search engine / adding to list - the code below removes all from list as I don't support adding to list, only setting as default - for (i in components.store.state.search.customSearchEngines) { - components.searchUseCases.removeSearchEngine(i) - } - - if (UserPreferences(this).customSearchEngine) { - // SECURITY: Use lifecycle-aware coroutine scope - lifecycleScope.launch { - val customSearch = - createSearchEngine( - name = "Custom Search", - url = UserPreferences(this@BrowserActivity).customSearchEngineURL, - icon = components.icons.loadIcon(IconRequest(UserPreferences(this@BrowserActivity).customSearchEngineURL)) - .await().bitmap - ) - - runOnUiThread { - components.searchUseCases.addSearchEngine( - customSearch - ) - components.searchUseCases.selectSearchEngine( - customSearch - ) - } - } - } else { - if (SearchEngineList(this).getEngines()[UserPreferences(this).searchEngineChoice].type == SearchEngine.Type.BUNDLED) { - components.searchUseCases.selectSearchEngine( - SearchEngineList(this).getEngines()[UserPreferences(this).searchEngineChoice] - ) - } else { - components.searchUseCases.addSearchEngine( - SearchEngineList(this).getEngines()[UserPreferences( - this - ).searchEngineChoice] - ) - components.searchUseCases.selectSearchEngine( - SearchEngineList(this).getEngines()[UserPreferences(this).searchEngineChoice] - ) - } - } - } - private fun updateToolbarAndStatusBarTheme() { val selectedTab = components.store.state.tabs.find { it.id == components.store.state.selectedTabId } val isPrivate = selectedTab?.content?.private == true diff --git a/app/src/main/java/com/prirai/android/nira/browser/SearchEngineList.kt b/app/src/main/java/com/prirai/android/nira/browser/SearchEngineList.kt index 314f0a5..a6aad02 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/SearchEngineList.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/SearchEngineList.kt @@ -30,7 +30,7 @@ class SearchEngineList(private val context: Context) { name = "Custom Search", icon = getIconBitmap(com.prirai.android.nira.R.drawable.ic_search), type = SearchEngine.Type.CUSTOM, - resultUrls = listOf(preferences.customSearchEngineURL) + resultUrls = listOf(normalizeCustomSearchUrl(preferences.customSearchEngineURL)) ) } val engines = getEngines() @@ -38,6 +38,20 @@ class SearchEngineList(private val context: Context) { return engines[index] } + companion object { + fun normalizeCustomSearchUrl(url: String): String { + return url.replace("%s", "{searchTerms}") + } + + fun toUserFacingSearchUrl(url: String): String { + return url.replace("{searchTerms}", "%s") + } + + fun isValidCustomSearchUrl(url: String): Boolean { + return url.contains("%s") || url.contains("{searchTerms}") + } + } + fun getEngines(): List { return listOf( SearchEngine( diff --git a/app/src/main/java/com/prirai/android/nira/browser/SearchEnginePreferences.kt b/app/src/main/java/com/prirai/android/nira/browser/SearchEnginePreferences.kt new file mode 100644 index 0000000..a9bf12c --- /dev/null +++ b/app/src/main/java/com/prirai/android/nira/browser/SearchEnginePreferences.kt @@ -0,0 +1,60 @@ +package com.prirai.android.nira.browser + +import android.content.Context +import com.prirai.android.nira.ext.components +import com.prirai.android.nira.preferences.UserPreferences +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.browser.state.state.searchEngines +import mozilla.components.feature.search.ext.createSearchEngine + +object SearchEnginePreferences { + + suspend fun apply(context: Context, private: Boolean = false) { + val prefs = UserPreferences(context) + val components = context.components + val catalog = SearchEngineList(context) + val fallback = if (private && prefs.privateSearchEngineChoice >= 0) { + catalog.getEngines()[prefs.privateSearchEngineChoice.coerceIn(catalog.getEngines().indices)] + } else { + catalog.getSelectedEngine(prefs) + } + + val selected = if (!private && prefs.customSearchEngine && prefs.customSearchEngineURL.isNotBlank()) { + val custom = createSearchEngine( + name = "Custom Search", + url = SearchEngineList.normalizeCustomSearchUrl(prefs.customSearchEngineURL), + icon = components.icons.loadIcon(IconRequest(prefs.customSearchEngineURL)).await().bitmap, + ) + val existing = components.store.state.search.searchEngines.find { + it.id == custom.id || it.resultUrls == custom.resultUrls + } + if (existing == null) { + components.searchUseCases.addSearchEngine(custom) + custom + } else { + existing + } + } else { + resolveFromStore(context, fallback) ?: fallback.also { engine -> + if (engine.type != SearchEngine.Type.BUNDLED || + components.store.state.search.searchEngines.none { it.id == engine.id } + ) { + components.searchUseCases.addSearchEngine(engine) + } + } + } + + if (private) { + components.searchUseCases.selectPrivateSearchEngine(selected) + } else { + components.searchUseCases.selectSearchEngine(selected) + } + } + + private fun resolveFromStore(context: Context, fallback: SearchEngine): SearchEngine? { + val engines = context.components.store.state.search.searchEngines + return engines.find { it.id == fallback.id } + ?: engines.find { it.name.equals(fallback.name, ignoreCase = true) } + } +} diff --git a/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileManager.kt b/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileManager.kt index 36d4112..b8fc981 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileManager.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileManager.kt @@ -3,10 +3,12 @@ package com.prirai.android.nira.browser.profile import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import com.prirai.android.nira.browser.SearchEnginePreferences import com.prirai.android.nira.ext.components import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.launch /** * Manages browser profiles - creation, deletion, and persistence @@ -71,6 +73,9 @@ class ProfileManager(private val context: Context) { */ fun setActiveProfile(profile: BrowserProfile) { prefs.edit { putString(KEY_ACTIVE_PROFILE_ID, profile.id)} + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + SearchEnginePreferences.apply(context, private = false) + } } /** diff --git a/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt b/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt index e636fa3..fa5e4da 100644 --- a/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt +++ b/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt @@ -34,6 +34,7 @@ class UserPreferences(appContext: Context) : mozilla.components.support.ktx.andr var searchEngineChoice by intPreference(SEARCH_ENGINE, 0) var customSearchEngine by booleanPreference(CUSTOM_SEARCH_ENGINE, false) var customSearchEngineURL by stringPreference(CUSTOM_SEARCH_ENGINE_URL, "") + var privateSearchEngineChoice by intPreference(PRIVATE_SEARCH_ENGINE, -1) var toolbarPosition by intPreference(TOOLBAR_POSITION, ToolbarPosition.BOTTOM.ordinal) var homepageType by intPreference(HOMEPAGE_TYPE, HomepageChoice.VIEW.ordinal) var customHomepageUrl by stringPreference(HOMEPAGE_URL, "") @@ -227,6 +228,7 @@ class UserPreferences(appContext: Context) : mozilla.components.support.ktx.andr const val DOH_MODE = "doh_mode" const val DOH_PROVIDER_URL = "doh_provider_url" const val GLOBAL_PRIVACY_CONTROL = "global_privacy_control" + const val PRIVATE_SEARCH_ENGINE = "private_search_engine" const val HTTPS_ONLY_OFF = 0 const val HTTPS_ONLY_PRIVATE = 1 diff --git a/app/src/main/java/com/prirai/android/nira/search/SearchDialogController.kt b/app/src/main/java/com/prirai/android/nira/search/SearchDialogController.kt index 22a58c3..4c49d76 100644 --- a/app/src/main/java/com/prirai/android/nira/search/SearchDialogController.kt +++ b/app/src/main/java/com/prirai/android/nira/search/SearchDialogController.kt @@ -64,7 +64,7 @@ class SearchDialogController( private fun resolveSearchEngine(): SearchEngine? { fragmentStore.state.searchEngineSource.searchEngine?.let { return it } fragmentStore.state.defaultEngine?.let { return it } - store.state.search.selectedOrDefaultSearchEngine?.let { return it } + store.state.search.selectedOrDefaultSearchEngine(activity.browsingModeManager.mode.isPrivate)?.let { return it } return try { SearchEngineList(activity).getSelectedEngine(UserPreferences(activity)) } catch (_: Exception) { diff --git a/app/src/main/java/com/prirai/android/nira/search/SearchFragmentStore.kt b/app/src/main/java/com/prirai/android/nira/search/SearchFragmentStore.kt index c1c5509..a13db2d 100644 --- a/app/src/main/java/com/prirai/android/nira/search/SearchFragmentStore.kt +++ b/app/src/main/java/com/prirai/android/nira/search/SearchFragmentStore.kt @@ -74,7 +74,8 @@ data class SearchFragmentState( val showBookmarkSuggestions: Boolean, val showSyncedTabsSuggestions: Boolean, val tabId: String?, - val pastedText: String? = null + val pastedText: String? = null, + val isPrivate: Boolean = false, ) : State fun createInitialSearchFragmentState( @@ -85,7 +86,8 @@ fun createInitialSearchFragmentState( ): SearchFragmentState { val tab = tabId?.let { components.store.state.findTab(it) } val url = tab?.content?.url.orEmpty() - val defaultEngine = components.store.state.search.selectedOrDefaultSearchEngine + val isPrivate = tab?.content?.private == true + val defaultEngine = components.store.state.search.selectedOrDefaultSearchEngine(isPrivate) ?: context?.let { try { SearchEngineList(it).getSelectedEngine(UserPreferences(it)) @@ -113,7 +115,8 @@ fun createInitialSearchFragmentState( showBookmarkSuggestions = true, showSyncedTabsSuggestions = true, tabId = tabId, - pastedText = pastedText + pastedText = pastedText, + isPrivate = isPrivate, ) } @@ -152,7 +155,7 @@ private fun searchStateReducer(state: SearchFragmentState, action: SearchFragmen is SearchFragmentAction.SetShowSearchSuggestions -> state.copy(showSearchSuggestions = action.show) is SearchFragmentAction.UpdateSearchState -> { - val resolvedEngine = action.search.selectedOrDefaultSearchEngine ?: state.defaultEngine + val resolvedEngine = action.search.selectedOrDefaultSearchEngine(state.isPrivate) ?: state.defaultEngine state.copy( defaultEngine = resolvedEngine, areShortcutsAvailable = action.search.searchEngines.size > 1, diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt index ceb7aa6..33dca40 100644 --- a/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt @@ -121,8 +121,9 @@ class AwesomeBarView( colorFilter = createBlendModeColorFilterCompat(primaryTextColor, SRC_IN) }.toBitmap() - val selectedEngine = components.store.state.search.selectedOrDefaultSearchEngine - ?: SearchEngineList(activity).getSelectedEngine(UserPreferences(activity)) + val selectedEngine = components.store.state.search.selectedOrDefaultSearchEngine( + activity.browsingModeManager.mode.isPrivate + ) ?: SearchEngineList(activity).getSelectedEngine(UserPreferences(activity)) val suggestionLimit = UserPreferences(activity).searchSuggestionCount.coerceIn(1, 10) defaultSearchSuggestionProvider = diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt index 5713e63..b4ccd59 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt @@ -5,11 +5,15 @@ import android.os.Bundle import android.text.InputType import android.widget.EditText import android.widget.Toast +import androidx.lifecycle.lifecycleScope import com.prirai.android.nira.R -import com.prirai.android.nira.settings.HomepageChoice import com.prirai.android.nira.browser.SearchEngineList +import com.prirai.android.nira.browser.SearchEnginePreferences +import com.prirai.android.nira.ext.components import com.prirai.android.nira.preferences.UserPreferences +import com.prirai.android.nira.settings.HomepageChoice import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch class GeneralSettingsFragment : BaseSettingsFragment() { @@ -28,7 +32,12 @@ class GeneralSettingsFragment : BaseSettingsFragment() { clickablePreference( preference = resources.getString(R.string.key_search_engine), - onClick = { pickSearchEngine() } + onClick = { pickSearchEngine(private = false) } + ) + + clickablePreference( + preference = resources.getString(R.string.key_private_search_engine), + onClick = { pickSearchEngine(private = true) } ) switchPreference( @@ -92,34 +101,44 @@ class GeneralSettingsFragment : BaseSettingsFragment() { .show() } - private fun pickSearchEngine(){ - val startingChoice = UserPreferences(requireContext()).searchEngineChoice - val singleItems = emptyList().toMutableList() - - for(i in SearchEngineList(requireContext()).getEngines()){ - singleItems.add(i.name) + private fun pickSearchEngine(private: Boolean){ + val prefs = UserPreferences(requireContext()) + val engines = SearchEngineList(requireContext()).getEngines() + val singleItems = engines.map { it.name }.toMutableList() + if (private) { + singleItems.add(0, getString(R.string.use_default_search_engine)) + } else { + singleItems.add(getString(R.string.custom)) } - singleItems.add(resources.getString(R.string.custom)) - - val checkedItem = if(!UserPreferences(requireContext()).customSearchEngine) UserPreferences(requireContext()).searchEngineChoice else singleItems.size - 1 + val checkedItem = if (private) { + if (prefs.privateSearchEngineChoice >= 0) prefs.privateSearchEngineChoice + 1 else 0 + } else if (prefs.customSearchEngine) { + singleItems.lastIndex + } else { + prefs.searchEngineChoice + } MaterialAlertDialogBuilder(requireContext()) - .setTitle(resources.getString(R.string.search_engine)) - .setNeutralButton(resources.getString(R.string.cancel)) { _, _ -> - UserPreferences(requireContext()).searchEngineChoice = startingChoice - } + .setTitle(if (private) getString(R.string.private_search_engine) else getString(R.string.search_engine)) + .setNeutralButton(resources.getString(R.string.cancel), null) .setPositiveButton(resources.getString(R.string.mozac_feature_prompts_ok)) { _, _ -> - Toast.makeText(context, requireContext().resources.getText(R.string.app_restart), Toast.LENGTH_LONG).show() + viewLifecycleOwner.lifecycleScope.launch { + SearchEnginePreferences.apply(requireContext(), private = private) + if (!private && prefs.privateSearchEngineChoice < 0) { + requireContext().components.searchUseCases.clearPrivateSearchEngine() + } + } } .setSingleChoiceItems(singleItems.toTypedArray(), checkedItem) { dialog, which -> - if(which == singleItems.size - 1){ + if (private) { + prefs.privateSearchEngineChoice = if (which == 0) -1 else which - 1 + } else if (which == singleItems.lastIndex) { customSearchEngineDialog() dialog.cancel() - } - else{ - UserPreferences(requireContext()).customSearchEngine = false - UserPreferences(requireContext()).searchEngineChoice = which + } else { + prefs.customSearchEngine = false + prefs.searchEngineChoice = which } } .show() @@ -134,15 +153,23 @@ class GeneralSettingsFragment : BaseSettingsFragment() { input.inputType = InputType.TYPE_CLASS_TEXT builder.setView(input) - input.setText(UserPreferences(requireContext()).customSearchEngineURL) + input.setText( + SearchEngineList.toUserFacingSearchUrl( + UserPreferences(requireContext()).customSearchEngineURL + ) + ) builder.setPositiveButton( "OK" ) { dialog, which -> - if(input.text.toString().contains("{searchTerms}")){ + val entered = input.text.toString() + if (SearchEngineList.isValidCustomSearchUrl(entered)) { UserPreferences(requireContext()).customSearchEngine = true - UserPreferences(requireContext()).customSearchEngineURL = input.text.toString() - Toast.makeText(context, requireContext().resources.getText(R.string.app_restart), Toast.LENGTH_LONG).show() + UserPreferences(requireContext()).customSearchEngineURL = + SearchEngineList.normalizeCustomSearchUrl(entered) + viewLifecycleOwner.lifecycleScope.launch { + SearchEnginePreferences.apply(requireContext(), private = false) + } } else{ Toast.makeText(context, R.string.custom_search_engine_error, Toast.LENGTH_LONG).show() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab21916..09e7c6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ app_theme_type web_theme_type search_engine + private_search_engine Failed Search engine + Private search engine + Used only in private tabs. Same as the default engine if unset. + Same as default Fill from clipboard @@ -453,10 +457,12 @@ Custom search engine Enter URL with \'{searchTerms}\' in place of the query + formatted="false" + >Enter URL with %s in place of the query Search URL does not contain \'{searchTerms}\' + formatted="false" + >Search URL does not contain %s Clear Name diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml index b49da6e..45f7f5d 100644 --- a/app/src/main/res/xml/preferences_general.xml +++ b/app/src/main/res/xml/preferences_general.xml @@ -12,6 +12,13 @@ app:icon="@drawable/ic_search" app:singleLineTitle="false"/> + + Date: Sun, 23 Aug 2026 16:10:38 +0530 Subject: [PATCH 09/20] feat: persist desktop mode and default it on large windows New tabs inherit BrowserState.desktopMode. Tablets use desktop unless the setting is changed. --- .../android/nira/components/Components.kt | 9 +++++++++ .../nira/preferences/UserPreferences.kt | 4 ++++ .../fragment/GeneralSettingsFragment.kt | 18 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 +++ app/src/main/res/xml/preferences_general.xml | 8 ++++++++ 5 files changed, 42 insertions(+) diff --git a/app/src/main/java/com/prirai/android/nira/components/Components.kt b/app/src/main/java/com/prirai/android/nira/components/Components.kt index ccd890b..c4b0939 100644 --- a/app/src/main/java/com/prirai/android/nira/components/Components.kt +++ b/app/src/main/java/com/prirai/android/nira/components/Components.kt @@ -29,6 +29,7 @@ import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage import mozilla.components.browser.icons.BrowserIcons import mozilla.components.browser.session.storage.SessionStorage +import mozilla.components.browser.state.action.DefaultDesktopModeAction import mozilla.components.browser.state.engine.EngineMiddleware import mozilla.components.browser.state.engine.middleware.SessionPrioritizationMiddleware import mozilla.components.browser.state.store.BrowserStore @@ -333,6 +334,14 @@ open class Components(private val applicationContext: Context) { ) MediaSessionFeature(applicationContext, MediaSessionService::class.java, this).start() + + val prefs = UserPreferences(applicationContext) + val desktopDefault = if (prefs.hasDesktopModeDefault()) { + prefs.desktopModeDefault + } else { + Utils().isTablet(applicationContext) + } + dispatch(DefaultDesktopModeAction.DesktopModeUpdated(desktopDefault)) } } diff --git a/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt b/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt index fa5e4da..1576f04 100644 --- a/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt +++ b/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt @@ -35,6 +35,9 @@ class UserPreferences(appContext: Context) : mozilla.components.support.ktx.andr var customSearchEngine by booleanPreference(CUSTOM_SEARCH_ENGINE, false) var customSearchEngineURL by stringPreference(CUSTOM_SEARCH_ENGINE_URL, "") var privateSearchEngineChoice by intPreference(PRIVATE_SEARCH_ENGINE, -1) + var desktopModeDefault by booleanPreference(DESKTOP_MODE_DEFAULT, false) + + fun hasDesktopModeDefault(): Boolean = preferences.contains(DESKTOP_MODE_DEFAULT) var toolbarPosition by intPreference(TOOLBAR_POSITION, ToolbarPosition.BOTTOM.ordinal) var homepageType by intPreference(HOMEPAGE_TYPE, HomepageChoice.VIEW.ordinal) var customHomepageUrl by stringPreference(HOMEPAGE_URL, "") @@ -229,6 +232,7 @@ class UserPreferences(appContext: Context) : mozilla.components.support.ktx.andr const val DOH_PROVIDER_URL = "doh_provider_url" const val GLOBAL_PRIVACY_CONTROL = "global_privacy_control" const val PRIVATE_SEARCH_ENGINE = "private_search_engine" + const val DESKTOP_MODE_DEFAULT = "desktop_mode_default" const val HTTPS_ONLY_OFF = 0 const val HTTPS_ONLY_PRIVATE = 1 diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt index b4ccd59..38fe125 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt @@ -12,8 +12,10 @@ import com.prirai.android.nira.browser.SearchEnginePreferences import com.prirai.android.nira.ext.components import com.prirai.android.nira.preferences.UserPreferences import com.prirai.android.nira.settings.HomepageChoice +import com.prirai.android.nira.utils.Utils import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch +import mozilla.components.browser.state.action.DefaultDesktopModeAction class GeneralSettingsFragment : BaseSettingsFragment() { @@ -40,6 +42,22 @@ class GeneralSettingsFragment : BaseSettingsFragment() { onClick = { pickSearchEngine(private = true) } ) + val desktopDefault = if (UserPreferences(requireContext()).hasDesktopModeDefault()) { + UserPreferences(requireContext()).desktopModeDefault + } else { + Utils().isTablet(requireContext()) + } + switchPreference( + preference = resources.getString(R.string.key_desktop_mode_default), + isChecked = desktopDefault, + onCheckChange = { + UserPreferences(requireContext()).desktopModeDefault = it + requireContext().components.store.dispatch( + DefaultDesktopModeAction.DesktopModeUpdated(it) + ) + } + ) + switchPreference( preference = requireContext().resources.getString(R.string.key_search_suggestions_enabled), isChecked = UserPreferences(requireContext()).searchSuggestionsEnabled, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09e7c6f..6362a25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ web_theme_type search_engine private_search_engine + desktop_mode_default New tab Close tab Desktop site + Request desktop site by default + New tabs open in desktop mode. Tablets and large windows use this unless you turn it off. Stop Install Cancel diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml index 45f7f5d..4d894d4 100644 --- a/app/src/main/res/xml/preferences_general.xml +++ b/app/src/main/res/xml/preferences_general.xml @@ -63,6 +63,14 @@ app:icon="@drawable/ic_home" app:singleLineTitle="false"/> + + Date: Sun, 23 Aug 2026 16:11:03 +0530 Subject: [PATCH 10/20] feat: add in-browser page translations Install TranslationsMiddleware and expose Translate page / Show original in the browser menu. --- .../prirai/android/nira/BrowserFragment.kt | 18 +++++++++++++++ .../android/nira/components/Components.kt | 6 +++++ .../nira/components/toolbar/BrowserMenu.kt | 14 ++++++++++++ .../toolbar/BrowserMenuController.kt | 22 +++++++++++++++++++ .../nira/components/toolbar/ToolbarMenu.kt | 1 + .../nira/preferences/UserPreferences.kt | 2 ++ .../fragment/GeneralSettingsFragment.kt | 8 +++++++ app/src/main/res/values/strings.xml | 5 +++++ app/src/main/res/xml/preferences_general.xml | 8 +++++++ 9 files changed, 84 insertions(+) diff --git a/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt b/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt index 77b8312..4e385a5 100644 --- a/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt @@ -869,6 +869,24 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { ) ) + if (UserPreferences(requireContext()).translationsEnabled && + selectedTab?.content?.url?.startsWith("http") == true + ) { + val translated = selectedTab.translationsState.isTranslated + menuItems.add( + com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( + id = "translate", + title = getString( + if (translated) R.string.show_original_page else R.string.translate_page + ), + iconRes = R.drawable.ic_language, + onClick = { + browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.Translate) + } + ) + ) + } + menuItems.add(com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Divider) // Add to Homescreen/Install (conditional) diff --git a/app/src/main/java/com/prirai/android/nira/components/Components.kt b/app/src/main/java/com/prirai/android/nira/components/Components.kt index c4b0939..d7d231f 100644 --- a/app/src/main/java/com/prirai/android/nira/components/Components.kt +++ b/app/src/main/java/com/prirai/android/nira/components/Components.kt @@ -32,6 +32,7 @@ import mozilla.components.browser.session.storage.SessionStorage import mozilla.components.browser.state.action.DefaultDesktopModeAction import mozilla.components.browser.state.engine.EngineMiddleware import mozilla.components.browser.state.engine.middleware.SessionPrioritizationMiddleware +import mozilla.components.browser.state.engine.middleware.TranslationsMiddleware import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.storage.sync.PlacesBookmarksStorage import mozilla.components.browser.storage.sync.PlacesHistoryStorage @@ -316,6 +317,11 @@ open class Components(private val applicationContext: Context) { com.prirai.android.nira.browser.tabgroups.TabGroupMiddleware(tabGroupManager), profileMiddleware, // Use the exposed instance SessionPrioritizationMiddleware(), + TranslationsMiddleware( + engine = engine, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main), + isTranslationsEnabled = { UserPreferences(applicationContext).translationsEnabled }, + ), EnhancedStateCaptureMiddleware( scope = CoroutineScope(SupervisorJob() + Dispatchers.Main), maxTabsToCapture = 3 diff --git a/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenu.kt b/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenu.kt index f0057dc..de917cf 100644 --- a/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenu.kt +++ b/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenu.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.LifecycleOwner import com.prirai.android.nira.R import com.prirai.android.nira.addons.AddonsActivity import com.prirai.android.nira.ext.components +import com.prirai.android.nira.preferences.UserPreferences import mozilla.components.browser.menu.WebExtensionBrowserMenuBuilder import mozilla.components.browser.menu.item.BrowserMenuDivider import mozilla.components.browser.menu.item.BrowserMenuItemToolbar @@ -135,6 +136,7 @@ class BrowserMenu( externalAppItem, sendTabItem, desktopMode, + translateItem, BrowserMenuDivider(), newPrivateTabItem, newTabItem, @@ -166,6 +168,18 @@ class BrowserMenu( onItemTapped.invoke(ToolbarMenu.Item.RequestDesktop(checked)) } + private val translateItem = ThemedBrowserMenuImageText( + label = context.getString(R.string.translate_page), + imageResource = R.drawable.ic_language + ) { + onItemTapped.invoke(ToolbarMenu.Item.Translate) + }.apply { + visible = { + UserPreferences(context).translationsEnabled && + selectedSession?.content?.url?.startsWith("http") == true + } + } + private val installWebApp = ThemedBrowserMenuImageText( label = context.getString(R.string.install_web_app), imageResource = R.drawable.ic_round_smartphone diff --git a/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenuController.kt b/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenuController.kt index 2697b23..599a94c 100644 --- a/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenuController.kt +++ b/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenuController.kt @@ -14,6 +14,7 @@ import com.prirai.android.nira.ssl.showSslDialog import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import mozilla.components.browser.state.action.EngineAction +import mozilla.components.browser.state.action.TranslationsAction import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.store.BrowserStore @@ -85,6 +86,27 @@ class DefaultBrowserToolbarMenuController( } } + is ToolbarMenu.Item.Translate -> { + val tab = currentSession ?: return + val translations = tab.translationsState + if (translations.isTranslated) { + store.dispatch(TranslationsAction.TranslateRestoreAction(tab.id)) + } else { + val detected = translations.translationEngineState?.detectedLanguages + val from = detected?.documentLangTag + val to = detected?.userPreferredLangTag + if (from.isNullOrBlank() || to.isNullOrBlank()) return + store.dispatch( + TranslationsAction.TranslateAction( + tabId = tab.id, + fromLanguage = from, + toLanguage = to, + options = null, + ) + ) + } + } + is ToolbarMenu.Item.InstallWebApp -> { currentSession?.let { session -> // Get current profile as default diff --git a/app/src/main/java/com/prirai/android/nira/components/toolbar/ToolbarMenu.kt b/app/src/main/java/com/prirai/android/nira/components/toolbar/ToolbarMenu.kt index 7533965..c19b294 100644 --- a/app/src/main/java/com/prirai/android/nira/components/toolbar/ToolbarMenu.kt +++ b/app/src/main/java/com/prirai/android/nira/components/toolbar/ToolbarMenu.kt @@ -7,6 +7,7 @@ interface ToolbarMenu { sealed class Item { data object Settings : Item() data class RequestDesktop(val isChecked: Boolean) : Item() + data object Translate : Item() data object FindInPage : Item() data object Share : Item() data class Back(val viewHistory: Boolean) : Item() diff --git a/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt b/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt index 1576f04..7d14cce 100644 --- a/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt +++ b/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt @@ -36,6 +36,7 @@ class UserPreferences(appContext: Context) : mozilla.components.support.ktx.andr var customSearchEngineURL by stringPreference(CUSTOM_SEARCH_ENGINE_URL, "") var privateSearchEngineChoice by intPreference(PRIVATE_SEARCH_ENGINE, -1) var desktopModeDefault by booleanPreference(DESKTOP_MODE_DEFAULT, false) + var translationsEnabled by booleanPreference(TRANSLATIONS_ENABLED, true) fun hasDesktopModeDefault(): Boolean = preferences.contains(DESKTOP_MODE_DEFAULT) var toolbarPosition by intPreference(TOOLBAR_POSITION, ToolbarPosition.BOTTOM.ordinal) @@ -233,6 +234,7 @@ class UserPreferences(appContext: Context) : mozilla.components.support.ktx.andr const val GLOBAL_PRIVACY_CONTROL = "global_privacy_control" const val PRIVATE_SEARCH_ENGINE = "private_search_engine" const val DESKTOP_MODE_DEFAULT = "desktop_mode_default" + const val TRANSLATIONS_ENABLED = "translations_enabled" const val HTTPS_ONLY_OFF = 0 const val HTTPS_ONLY_PRIVATE = 1 diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt index 38fe125..bb76948 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt @@ -58,6 +58,14 @@ class GeneralSettingsFragment : BaseSettingsFragment() { } ) + switchPreference( + preference = resources.getString(R.string.key_translations_enabled), + isChecked = UserPreferences(requireContext()).translationsEnabled, + onCheckChange = { + UserPreferences(requireContext()).translationsEnabled = it + } + ) + switchPreference( preference = requireContext().resources.getString(R.string.key_search_suggestions_enabled), isChecked = UserPreferences(requireContext()).searchSuggestionsEnabled, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6362a25..2909305 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,7 @@ search_engine private_search_engine desktop_mode_default + translations_enabled Desktop site Request desktop site by default New tabs open in desktop mode. Tablets and large windows use this unless you turn it off. + Page translations + Offer to translate pages in another language + Translate page + Show original Stop Install Cancel diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml index 4d894d4..fd70d13 100644 --- a/app/src/main/res/xml/preferences_general.xml +++ b/app/src/main/res/xml/preferences_general.xml @@ -71,6 +71,14 @@ app:singleLineTitle="false" app:defaultValue="false"/> + + Date: Sun, 23 Aug 2026 16:11:12 +0530 Subject: [PATCH 11/20] feat: enable and disable addons per profile Remember each profile's addon set and apply it on profile switch. One Gecko runtime still hosts the extensions. --- .../com/prirai/android/nira/BrowserApp.kt | 6 +++ .../addons/InstalledAddonDetailsActivity.kt | 6 +++ .../browser/profile/ProfileAddonPolicy.kt | 51 +++++++++++++++++++ .../nira/browser/profile/ProfileManager.kt | 1 + 4 files changed, 64 insertions(+) create mode 100644 app/src/main/java/com/prirai/android/nira/browser/profile/ProfileAddonPolicy.kt diff --git a/app/src/main/java/com/prirai/android/nira/BrowserApp.kt b/app/src/main/java/com/prirai/android/nira/BrowserApp.kt index 43da8e6..c82dff3 100644 --- a/app/src/main/java/com/prirai/android/nira/BrowserApp.kt +++ b/app/src/main/java/com/prirai/android/nira/BrowserApp.kt @@ -200,6 +200,12 @@ class BrowserApp : Application() { }, onExtensionsLoaded = { extensions -> components.addonUpdater.registerForFutureUpdates(extensions) + applicationScope.launch(Dispatchers.IO) { + val profileId = com.prirai.android.nira.browser.profile.ProfileManager + .getInstance(this@BrowserApp).getActiveProfile().id + com.prirai.android.nira.browser.profile.ProfileAddonPolicy + .applyForProfile(this@BrowserApp, profileId) + } }, onUpdatePermissionRequest = components.addonUpdater::onUpdatePermissionRequest, ) diff --git a/app/src/main/java/com/prirai/android/nira/addons/InstalledAddonDetailsActivity.kt b/app/src/main/java/com/prirai/android/nira/addons/InstalledAddonDetailsActivity.kt index 529824a..8c25944 100644 --- a/app/src/main/java/com/prirai/android/nira/addons/InstalledAddonDetailsActivity.kt +++ b/app/src/main/java/com/prirai/android/nira/addons/InstalledAddonDetailsActivity.kt @@ -110,10 +110,14 @@ class InstalledAddonDetailsActivity : AppCompatActivity() { val switch = findViewById(R.id.enable_switch) switch.isChecked = addon.isEnabled() switch.setOnCheckedChangeListener { _, isChecked -> + val profileId = com.prirai.android.nira.browser.profile.ProfileManager + .getInstance(this).getActiveProfile().id if (isChecked) { this.components.addonManager.enableAddon( addon, onSuccess = { + com.prirai.android.nira.browser.profile.ProfileAddonPolicy + .setEnabledForProfile(this, profileId, addon.id, true) switch.isChecked = true Toast.makeText( this, @@ -133,6 +137,8 @@ class InstalledAddonDetailsActivity : AppCompatActivity() { this.components.addonManager.disableAddon( addon, onSuccess = { + com.prirai.android.nira.browser.profile.ProfileAddonPolicy + .setEnabledForProfile(this, profileId, addon.id, false) switch.isChecked = false Toast.makeText( this, diff --git a/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileAddonPolicy.kt b/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileAddonPolicy.kt new file mode 100644 index 0000000..a73252a --- /dev/null +++ b/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileAddonPolicy.kt @@ -0,0 +1,51 @@ +package com.prirai.android.nira.browser.profile + +import android.content.Context +import androidx.core.content.edit +import com.prirai.android.nira.ext.components +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object ProfileAddonPolicy { + private const val PREFS = "profile_addons" + private const val KEY_PREFIX = "enabled_" + + fun isEnabledForProfile(context: Context, profileId: String, addonId: String): Boolean { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val key = KEY_PREFIX + profileId + if (!prefs.contains(key)) { + return true + } + return prefs.getStringSet(key, emptySet()).orEmpty().contains(addonId) + } + + fun setEnabledForProfile(context: Context, profileId: String, addonId: String, enabled: Boolean) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val key = KEY_PREFIX + profileId + val current = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf(addonId) + if (enabled) current.add(addonId) else current.remove(addonId) + prefs.edit { putStringSet(key, current) } + } + + suspend fun applyForProfile(context: Context, profileId: String) { + val addons = withContext(Dispatchers.IO) { + runCatching { context.components.addonManager.getAddons() }.getOrDefault(emptyList()) + } + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val key = KEY_PREFIX + profileId + if (!prefs.contains(key)) { + val enabled = addons.filter { it.isEnabled() }.map { it.id }.toSet() + prefs.edit { putStringSet(key, enabled) } + return + } + val allowed = prefs.getStringSet(key, emptySet()).orEmpty() + addons.forEach { addon -> + val shouldEnable = allowed.contains(addon.id) + if (shouldEnable && !addon.isEnabled()) { + context.components.addonManager.enableAddon(addon) + } else if (!shouldEnable && addon.isEnabled()) { + context.components.addonManager.disableAddon(addon) + } + } + } +} diff --git a/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileManager.kt b/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileManager.kt index b8fc981..670ba86 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileManager.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileManager.kt @@ -74,6 +74,7 @@ class ProfileManager(private val context: Context) { fun setActiveProfile(profile: BrowserProfile) { prefs.edit { putString(KEY_ACTIVE_PROFILE_ID, profile.id)} kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + ProfileAddonPolicy.applyForProfile(context, profile.id) SearchEnginePreferences.apply(context, private = false) } } From 5caa2736416de4a53241b65703748ef36e587b1a Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 18:16:37 +0530 Subject: [PATCH 12/20] fix: apply profile addon enable/disable on the main thread GeckoWebExtensionController requires a Handler thread; hopping off Dispatchers.IO avoids IllegalThreadStateException. --- .../nira/browser/profile/ProfileAddonPolicy.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileAddonPolicy.kt b/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileAddonPolicy.kt index a73252a..e9a4cbe 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileAddonPolicy.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/profile/ProfileAddonPolicy.kt @@ -39,12 +39,14 @@ object ProfileAddonPolicy { return } val allowed = prefs.getStringSet(key, emptySet()).orEmpty() - addons.forEach { addon -> - val shouldEnable = allowed.contains(addon.id) - if (shouldEnable && !addon.isEnabled()) { - context.components.addonManager.enableAddon(addon) - } else if (!shouldEnable && addon.isEnabled()) { - context.components.addonManager.disableAddon(addon) + withContext(Dispatchers.Main) { + addons.forEach { addon -> + val shouldEnable = allowed.contains(addon.id) + if (shouldEnable && !addon.isEnabled()) { + context.components.addonManager.enableAddon(addon) + } else if (!shouldEnable && addon.isEnabled()) { + context.components.addonManager.disableAddon(addon) + } } } } From 722c033507fef9b3ee049b828a1db8750f2b68c1 Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 18:16:44 +0530 Subject: [PATCH 13/20] fix: slim ungrouped tab pills and center them in the row Ungrouped pills are 32dp with a 1dp selected stroke so they sit vertically centered next to grouped tabs. --- .../nira/browser/tabs/compose/SwipeableTabPill.kt | 8 +++++--- .../nira/browser/tabs/compose/TabBarCompose.kt | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/SwipeableTabPill.kt b/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/SwipeableTabPill.kt index 3886f20..1e115c3 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/SwipeableTabPill.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/SwipeableTabPill.kt @@ -176,15 +176,17 @@ fun SwipeableTabPill( shape = RoundedCornerShape(12.dp), color = when { isSelected && groupColor != null -> Color(groupColor).copy(alpha = 0.3f) - isSelected -> MaterialTheme.colorScheme.primaryContainer + isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) else -> Color.Transparent }, border = if (isSelected) { BorderStroke( - 1.5.dp, + 1.dp, groupColor?.let { Color(it) } ?: MaterialTheme.colorScheme.primary ) - } else null + } else { + null + } ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/TabBarCompose.kt b/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/TabBarCompose.kt index 4e64ebc..dbc0c67 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/TabBarCompose.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/TabBarCompose.kt @@ -201,6 +201,7 @@ fun TabBarCompose( state = listState, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { itemsIndexed( @@ -549,11 +550,19 @@ private fun TabPill( ) { Surface( modifier = modifier - .height(40.dp) + .height(32.dp) .width(100.dp), shape = RoundedCornerShape(12.dp), - color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - border = if (isSelected) BorderStroke(1.5.dp, MaterialTheme.colorScheme.primary) else null + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) + } else { + Color.Transparent + }, + border = if (isSelected) { + BorderStroke(1.dp, MaterialTheme.colorScheme.primary) + } else { + null + } ) { Row( modifier = Modifier From 8a31f6dc88233f0a77dded599d899d6b047be0d8 Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 18:16:52 +0530 Subject: [PATCH 14/20] feat: use Fenix mozac icons on menus, settings, and toolbar --- .../prirai/android/nira/BrowserFragment.kt | 28 ++++++++-------- .../nira/browser/home/ComposeHomeFragment.kt | 8 ++--- .../android/nira/browser/home/HomeMenu.kt | 8 ++--- .../nira/browser/home/compose/HomeScreen.kt | 4 +-- .../nira/components/toolbar/BrowserMenu.kt | 32 +++++++++---------- .../toolbar/unified/UnifiedToolbar.kt | 2 +- .../nira/integration/ReaderModeIntegration.kt | 4 +-- .../ReloadStopButtonIntegration.kt | 4 +-- .../nira/search/awesomebar/AwesomeBarView.kt | 4 +-- .../nira/toolbar/ContextualBottomToolbar.kt | 12 +++---- .../res/xml/preferences_customization.xml | 2 +- app/src/main/res/xml/preferences_general.xml | 14 ++++---- app/src/main/res/xml/preferences_headers.xml | 16 +++++----- 13 files changed, 69 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt b/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt index 4e385a5..0e2a1ce 100644 --- a/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/BrowserFragment.kt @@ -777,22 +777,22 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { menuItems.add( com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.QuadRow( title1 = getString(R.string.action_history), - icon1 = R.drawable.ic_baseline_history, + icon1 = mozilla.components.ui.icons.R.drawable.mozac_ic_history_24, onClick1 = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.History) }, title2 = getString(R.string.action_bookmarks), - icon2 = R.drawable.ic_baseline_bookmark, + icon2 = mozilla.components.ui.icons.R.drawable.mozac_ic_bookmark_24, onClick2 = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.Bookmarks) }, title3 = getString(R.string.action_print), - icon3 = R.drawable.ic_baseline_print, + icon3 = mozilla.components.ui.icons.R.drawable.mozac_ic_print_24, onClick3 = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.Print) }, title4 = getString(R.string.save_as_pdf), - icon4 = R.drawable.ic_baseline_pdf, + icon4 = mozilla.components.ui.icons.R.drawable.mozac_ic_save_file_24, onClick4 = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.PDF) } @@ -814,7 +814,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { ), com.prirai.android.nira.components.menu.Material3BrowserMenu.IconRowItem( title = "Add Bookmark", - iconRes = R.drawable.ic_baseline_bookmark_add, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_bookmark_fill_24, onClick = { selectedTab?.let { tab -> val title = tab.content.title.ifEmpty { tab.content.url } @@ -838,7 +838,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { ), com.prirai.android.nira.components.menu.Material3BrowserMenu.IconRowItem( title = "Favorites", - iconRes = R.drawable.ic_baseline_star_24, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_pin_fill_24, onClick = { selectedTab?.let { tab -> val title = tab.content.title.ifEmpty { tab.content.url } @@ -859,7 +859,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Toggle( id = "desktop_mode", title = getString(R.string.desktop_mode), - iconRes = R.drawable.ic_desktop, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_device_desktop_24, isChecked = selectedTab?.content?.desktopMode ?: false, onToggle = { checked -> browserInteractor.onBrowserToolbarMenuItemTapped( @@ -879,7 +879,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { title = getString( if (translated) R.string.show_original_page else R.string.translate_page ), - iconRes = R.drawable.ic_language, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_translate_24, onClick = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.Translate) } @@ -896,7 +896,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "install_webapp", title = getString(R.string.install_web_app), - iconRes = R.drawable.ic_round_smartphone, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_device_mobile_24, onClick = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.InstallWebApp) } @@ -907,7 +907,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "add_to_homescreen", title = getString(R.string.action_add_to_homescreen), - iconRes = R.drawable.ic_round_smartphone, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_device_mobile_24, onClick = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.AddToHomeScreen) } @@ -923,7 +923,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "open_in_app", title = getString(R.string.mozac_feature_contextmenu_open_link_in_external_app), - iconRes = R.drawable.ic_baseline_open_in_new, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_external_link_24, onClick = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.OpenInApp) } @@ -950,7 +950,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "downloads", title = "Downloads", - iconRes = R.drawable.ic_baseline_download_24, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_download_24, onClick = { val downloadsBottomSheet = com.prirai.android.nira.downloads.DownloadsBottomSheetFragment.newInstance() downloadsBottomSheet.show(parentFragmentManager, com.prirai.android.nira.downloads.DownloadsBottomSheetFragment.TAG) @@ -963,7 +963,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "settings", title = getString(R.string.settings), - iconRes = R.drawable.ic_round_settings, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_settings_24, onClick = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.Settings) } @@ -988,7 +988,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "new_private_tab", title = getString(R.string.mozac_browser_menu_new_private_tab), - iconRes = R.drawable.ic_incognito, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_private_mode_24, onClick = { browserInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.NewPrivateTab) } diff --git a/app/src/main/java/com/prirai/android/nira/browser/home/ComposeHomeFragment.kt b/app/src/main/java/com/prirai/android/nira/browser/home/ComposeHomeFragment.kt index c881402..8b5bcd0 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/home/ComposeHomeFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/home/ComposeHomeFragment.kt @@ -765,7 +765,7 @@ class ComposeHomeFragment : Fragment() { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "new_private_tab", title = getString(R.string.mozac_browser_menu_new_private_tab), - iconRes = R.drawable.ic_incognito, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_private_mode_24, onClick = { browsingModeManager.mode = BrowsingMode.Private components.tabsUseCases.addTab( @@ -780,7 +780,7 @@ class ComposeHomeFragment : Fragment() { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "history", title = getString(R.string.action_history), - iconRes = R.drawable.ic_baseline_history, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_history_24, onClick = { startActivity(android.content.Intent( requireContext(), @@ -793,7 +793,7 @@ class ComposeHomeFragment : Fragment() { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "bookmarks", title = getString(R.string.action_bookmarks), - iconRes = R.drawable.ic_baseline_bookmark, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_bookmark_24, onClick = { val bookmarksBottomSheet = BookmarksBottomSheetFragment.newInstance() bookmarksBottomSheet.show(parentFragmentManager, "BookmarksBottomSheet") @@ -803,7 +803,7 @@ class ComposeHomeFragment : Fragment() { com.prirai.android.nira.components.menu.Material3BrowserMenu.MenuItem.Action( id = "settings", title = getString(R.string.settings), - iconRes = R.drawable.ic_round_settings, + iconRes = mozilla.components.ui.icons.R.drawable.mozac_ic_settings_24, onClick = { startActivity(android.content.Intent( requireContext(), diff --git a/app/src/main/java/com/prirai/android/nira/browser/home/HomeMenu.kt b/app/src/main/java/com/prirai/android/nira/browser/home/HomeMenu.kt index 7298315..d671226 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/home/HomeMenu.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/home/HomeMenu.kt @@ -39,12 +39,12 @@ class HomeMenu( val newPrivateTabItem = ThemedBrowserMenuImageText( context.getString(R.string.mozac_browser_menu_new_private_tab), - R.drawable.ic_incognito + mozilla.components.ui.icons.R.drawable.mozac_ic_private_mode_24 ) { onItemTapped.invoke(Item.NewPrivateTab) } - val bookmarksIcon = R.drawable.ic_baseline_bookmark + val bookmarksIcon = mozilla.components.ui.icons.R.drawable.mozac_ic_bookmark_24 val bookmarksItem = ThemedBrowserMenuImageText( context.getString(R.string.action_bookmarks), @@ -55,7 +55,7 @@ class HomeMenu( val historyItem = ThemedBrowserMenuImageText( context.getString(R.string.action_history), - R.drawable.ic_baseline_history + mozilla.components.ui.icons.R.drawable.mozac_ic_history_24 ) { onItemTapped.invoke(Item.History) } @@ -69,7 +69,7 @@ class HomeMenu( val settingsItem = ThemedBrowserMenuImageText( context.getString(R.string.settings), - R.drawable.ic_round_settings + mozilla.components.ui.icons.R.drawable.mozac_ic_settings_24 ) { onItemTapped.invoke(Item.Settings) } diff --git a/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeScreen.kt b/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeScreen.kt index e5d3356..f86d191 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeScreen.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/home/compose/HomeScreen.kt @@ -532,13 +532,13 @@ fun LibraryLinksRow( ) { LibraryLinkButton( label = "History", - icon = R.drawable.ic_baseline_history, + icon = mozilla.components.ui.icons.R.drawable.mozac_ic_history_24, onClick = onHistoryClick, modifier = Modifier.weight(1f) ) LibraryLinkButton( label = "Bookmarks", - icon = R.drawable.ic_baseline_bookmark, + icon = mozilla.components.ui.icons.R.drawable.mozac_ic_bookmark_24, onClick = onBookmarksClick, modifier = Modifier.weight(1f) ) diff --git a/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenu.kt b/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenu.kt index de917cf..152d470 100644 --- a/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenu.kt +++ b/app/src/main/java/com/prirai/android/nira/components/toolbar/BrowserMenu.kt @@ -46,7 +46,7 @@ class BrowserMenu( override val menuToolbar by lazy { val back = BrowserMenuItemToolbar.TwoStateButton( - primaryImageResource = R.drawable.ic_ios_back, + primaryImageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_back_24, primaryContentDescription = null.toString(), primaryImageTintResource = primaryTextColor(), isInPrimaryState = { @@ -60,7 +60,7 @@ class BrowserMenu( } val forward = BrowserMenuItemToolbar.TwoStateButton( - primaryImageResource = R.drawable.ic_ios_forward, + primaryImageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_forward_24, primaryContentDescription = context.getString(R.string.forward), primaryImageTintResource = primaryTextColor(), isInPrimaryState = { @@ -94,7 +94,7 @@ class BrowserMenu( } val share = BrowserMenuItemToolbar.Button( - imageResource = R.drawable.ios_share_24, + imageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_share_android_24, contentDescription = context.getString(R.string.mozac_selection_context_menu_share), iconTintColorResource = primaryTextColor(), listener = { @@ -107,7 +107,7 @@ class BrowserMenu( val externalAppItem = ThemedBrowserMenuImageText( context.getString(R.string.mozac_feature_contextmenu_open_link_in_external_app), - R.drawable.ic_baseline_open_in_new + mozilla.components.ui.icons.R.drawable.mozac_ic_external_link_24 ) { onItemTapped.invoke(ToolbarMenu.Item.OpenInApp) }.apply { @@ -153,13 +153,13 @@ class BrowserMenu( private val settings = ThemedBrowserMenuImageText( label = context.getString(R.string.settings), - imageResource = R.drawable.ic_round_settings + imageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_settings_24 ) { onItemTapped.invoke(ToolbarMenu.Item.Settings) } private val desktopMode = BrowserMenuImageCheckbox( - imageResource = R.drawable.ic_desktop, + imageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_device_desktop_24, label = context.getString(R.string.desktop_mode), initialState = { selectedSession?.content?.desktopMode ?: false @@ -170,7 +170,7 @@ class BrowserMenu( private val translateItem = ThemedBrowserMenuImageText( label = context.getString(R.string.translate_page), - imageResource = R.drawable.ic_language + imageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_translate_24 ) { onItemTapped.invoke(ToolbarMenu.Item.Translate) }.apply { @@ -182,7 +182,7 @@ class BrowserMenu( private val installWebApp = ThemedBrowserMenuImageText( label = context.getString(R.string.install_web_app), - imageResource = R.drawable.ic_round_smartphone + imageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_device_mobile_24 ) { onItemTapped.invoke(ToolbarMenu.Item.InstallWebApp) }.apply { @@ -193,7 +193,7 @@ class BrowserMenu( private val addToHomescreen = ThemedBrowserMenuImageText( label = context.getString(R.string.action_add_to_homescreen), - imageResource = R.drawable.ic_round_smartphone + imageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_add_to_homescreen_24 ) { onItemTapped.invoke(ToolbarMenu.Item.AddToHomeScreen) } @@ -207,21 +207,21 @@ class BrowserMenu( val historyItem = ThemedBrowserMenuImageText( context.getString(R.string.action_history), - R.drawable.ic_baseline_history + mozilla.components.ui.icons.R.drawable.mozac_ic_history_24 ) { onItemTapped.invoke(ToolbarMenu.Item.History) } val printItem = ThemedBrowserMenuImageText( context.getString(R.string.action_print), - R.drawable.ic_baseline_print + mozilla.components.ui.icons.R.drawable.mozac_ic_print_24 ) { onItemTapped.invoke(ToolbarMenu.Item.Print) } val saveAsPdfItem = ThemedBrowserMenuImageText( context.getString(R.string.save_as_pdf), - R.drawable.ic_baseline_pdf + mozilla.components.ui.icons.R.drawable.mozac_ic_save_file_24 ) { onItemTapped.invoke(ToolbarMenu.Item.PDF) } @@ -235,28 +235,28 @@ class BrowserMenu( val newPrivateTabItem = ThemedBrowserMenuImageText( context.getString(R.string.mozac_browser_menu_new_private_tab), - R.drawable.ic_incognito + mozilla.components.ui.icons.R.drawable.mozac_ic_private_mode_24 ) { onItemTapped.invoke(ToolbarMenu.Item.NewPrivateTab) } val bookmarksItem = ThemedBrowserMenuImageText( context.getString(R.string.action_bookmarks), - R.drawable.ic_baseline_bookmark + mozilla.components.ui.icons.R.drawable.mozac_ic_bookmark_24 ) { onItemTapped.invoke(ToolbarMenu.Item.Bookmarks) } val securityItem = ThemedBrowserMenuImageText( label = "Security", - imageResource = R.drawable.ic_baseline_lock + imageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_lock_24 ) { onItemTapped.invoke(ToolbarMenu.Item.Security) } val sendTabItem = ThemedBrowserMenuImageText( label = context.getString(R.string.send_tab_to_device), - imageResource = R.drawable.ios_share_24 + imageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_device_desktop_send_24 ) { onItemTapped.invoke(ToolbarMenu.Item.SendTabToDevice) }.apply { diff --git a/app/src/main/java/com/prirai/android/nira/components/toolbar/unified/UnifiedToolbar.kt b/app/src/main/java/com/prirai/android/nira/components/toolbar/unified/UnifiedToolbar.kt index 5fb79f8..fe9954e 100644 --- a/app/src/main/java/com/prirai/android/nira/components/toolbar/unified/UnifiedToolbar.kt +++ b/app/src/main/java/com/prirai/android/nira/components/toolbar/unified/UnifiedToolbar.kt @@ -535,7 +535,7 @@ class UnifiedToolbar @JvmOverloads constructor( // Add menu button first val menuAction = BrowserToolbar.Button( - imageDrawable = ContextCompat.getDrawable(context, com.prirai.android.nira.R.drawable.ic_more_vert)!!, + imageDrawable = ContextCompat.getDrawable(context, mozilla.components.ui.icons.R.drawable.mozac_ic_ellipsis_vertical_24)!!, contentDescription = "Menu", listener = { contextualToolbarListener?.onMenuClicked() diff --git a/app/src/main/java/com/prirai/android/nira/integration/ReaderModeIntegration.kt b/app/src/main/java/com/prirai/android/nira/integration/ReaderModeIntegration.kt index 3b0a00d..6a6df75 100644 --- a/app/src/main/java/com/prirai/android/nira/integration/ReaderModeIntegration.kt +++ b/app/src/main/java/com/prirai/android/nira/integration/ReaderModeIntegration.kt @@ -29,10 +29,10 @@ class ReaderModeIntegration( private var readerViewButtonVisible = false private val readerViewButton: BrowserToolbar.ToggleButton = BrowserToolbar.ToggleButton( - image = ContextCompat.getDrawable(context, R.drawable.ic_reader_mode)!!.mutate().apply { + image = ContextCompat.getDrawable(context, mozilla.components.ui.icons.R.drawable.mozac_ic_reader_view_24)!!.mutate().apply { setTint(context.getColorFromAttr(android.R.attr.textColorPrimary)) }, - imageSelected = ContextCompat.getDrawable(context, R.drawable.ic_reader_mode)!!.mutate().apply { + imageSelected = ContextCompat.getDrawable(context, mozilla.components.ui.icons.R.drawable.mozac_ic_reader_view_fill_24)!!.mutate().apply { setTint(ContextCompat.getColor(context, R.color.photonBlue40)) }, contentDescription = context.getString(R.string.mozac_reader_view_description), diff --git a/app/src/main/java/com/prirai/android/nira/integration/ReloadStopButtonIntegration.kt b/app/src/main/java/com/prirai/android/nira/integration/ReloadStopButtonIntegration.kt index 1463c99..0c139f4 100644 --- a/app/src/main/java/com/prirai/android/nira/integration/ReloadStopButtonIntegration.kt +++ b/app/src/main/java/com/prirai/android/nira/integration/ReloadStopButtonIntegration.kt @@ -30,11 +30,11 @@ class ReloadStopButtonIntegration( private var scope: CoroutineScope? = null private val reloadStopButton = BrowserToolbar.TwoStateButton( - primaryImage = ContextCompat.getDrawable(context, R.drawable.ic_refresh)!!.mutate().apply { + primaryImage = ContextCompat.getDrawable(context, mozilla.components.ui.icons.R.drawable.mozac_ic_arrow_clockwise_24)!!.mutate().apply { setTint(context.getColorFromAttr(android.R.attr.textColorPrimary)) }, primaryContentDescription = context.getString(R.string.reload), - secondaryImage = ContextCompat.getDrawable(context, R.drawable.ic_round_close)!!.mutate().apply { + secondaryImage = ContextCompat.getDrawable(context, mozilla.components.ui.icons.R.drawable.mozac_ic_cross_24)!!.mutate().apply { setTint(context.getColorFromAttr(android.R.attr.textColorPrimary)) }, secondaryContentDescription = context.getString(R.string.stop), diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt index 33dca40..f2f6d11 100644 --- a/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt @@ -117,7 +117,7 @@ class AwesomeBarView( suggestionsHeader = activity.getString(R.string.action_bookmarks) ) - val searchBitmap = getDrawable(activity, R.drawable.ic_ios_search)!!.apply { + val searchBitmap = getDrawable(activity, mozilla.components.ui.icons.R.drawable.mozac_ic_search_24)!!.apply { colorFilter = createBlendModeColorFilterCompat(primaryTextColor, SRC_IN) }.toBitmap() @@ -292,7 +292,7 @@ class AwesomeBarView( val components = activity.components val primaryTextColor = activity.getColorFromAttr(android.R.attr.textColorPrimary) - val searchBitmap = getDrawable(activity, R.drawable.ic_ios_search)!!.apply { + val searchBitmap = getDrawable(activity, mozilla.components.ui.icons.R.drawable.mozac_ic_search_24)!!.apply { colorFilter = createBlendModeColorFilterCompat(primaryTextColor, SRC_IN) }.toBitmap() diff --git a/app/src/main/java/com/prirai/android/nira/toolbar/ContextualBottomToolbar.kt b/app/src/main/java/com/prirai/android/nira/toolbar/ContextualBottomToolbar.kt index b9cfe5e..97d2c9d 100644 --- a/app/src/main/java/com/prirai/android/nira/toolbar/ContextualBottomToolbar.kt +++ b/app/src/main/java/com/prirai/android/nira/toolbar/ContextualBottomToolbar.kt @@ -170,14 +170,14 @@ class ContextualBottomToolbar @JvmOverloads constructor( this.visibility = VISIBLE backButton.visibility = VISIBLE - backButton.setImageResource(R.drawable.ic_baseline_bookmark) + backButton.setImageResource(mozilla.components.ui.icons.R.drawable.mozac_ic_bookmark_24) backButton.isEnabled = true backButton.alpha = 1.0f isShowingBookmarkIcon = true forwardButton.visibility = VISIBLE - forwardButton.setImageResource(R.drawable.ic_ios_forward) + forwardButton.setImageResource(mozilla.components.ui.icons.R.drawable.mozac_ic_forward_24) forwardButton.isEnabled = canGoForward forwardButton.alpha = if (canGoForward) 1.0f else 0.4f @@ -197,7 +197,7 @@ class ContextualBottomToolbar @JvmOverloads constructor( private fun showWebsiteContext(canGoBack: Boolean, tabCount: Int) { backButton.visibility = VISIBLE - backButton.setImageResource(R.drawable.ic_ios_back) + backButton.setImageResource(mozilla.components.ui.icons.R.drawable.mozac_ic_back_24) backButton.isEnabled = canGoBack backButton.alpha = if (canGoBack) 1.0f else 0.4f @@ -223,14 +223,14 @@ class ContextualBottomToolbar @JvmOverloads constructor( private fun showFullNavigationContext(tabCount: Int) { backButton.visibility = VISIBLE - backButton.setImageResource(R.drawable.ic_ios_back) + backButton.setImageResource(mozilla.components.ui.icons.R.drawable.mozac_ic_back_24) backButton.isEnabled = true backButton.alpha = 1.0f isShowingBookmarkIcon = false forwardButton.visibility = VISIBLE - forwardButton.setImageResource(R.drawable.ic_ios_forward) + forwardButton.setImageResource(mozilla.components.ui.icons.R.drawable.mozac_ic_forward_24) forwardButton.isEnabled = true forwardButton.alpha = 1.0f @@ -250,7 +250,7 @@ class ContextualBottomToolbar @JvmOverloads constructor( private fun showDefaultContext(tabCount: Int) { backButton.visibility = VISIBLE - backButton.setImageResource(R.drawable.ic_ios_back) + backButton.setImageResource(mozilla.components.ui.icons.R.drawable.mozac_ic_back_24) backButton.isEnabled = true backButton.alpha = 1.0f diff --git a/app/src/main/res/xml/preferences_customization.xml b/app/src/main/res/xml/preferences_customization.xml index 527d887..eb9a2e6 100644 --- a/app/src/main/res/xml/preferences_customization.xml +++ b/app/src/main/res/xml/preferences_customization.xml @@ -9,7 +9,7 @@ app:key="@string/key_app_theme_type" app:title="@string/theme" app:summary="@string/theme_explain" - app:icon="@drawable/ic_palette" + app:icon="@drawable/mozac_ic_themes_24" app:singleLineTitle="false"/> @@ -60,14 +60,14 @@ @@ -75,7 +75,7 @@ app:key="@string/key_translations_enabled" app:title="@string/translations_enabled" app:summary="@string/translations_enabled_summary" - app:icon="@drawable/ic_language" + app:icon="@drawable/mozac_ic_translate_24" app:singleLineTitle="false" app:defaultValue="true"/> diff --git a/app/src/main/res/xml/preferences_headers.xml b/app/src/main/res/xml/preferences_headers.xml index ae11b9a..3d2ec92 100644 --- a/app/src/main/res/xml/preferences_headers.xml +++ b/app/src/main/res/xml/preferences_headers.xml @@ -10,7 +10,7 @@ @@ -18,14 +18,14 @@ @@ -33,14 +33,14 @@ @@ -56,7 +56,7 @@ @@ -64,7 +64,7 @@ @@ -72,7 +72,7 @@ From cb04be1d4b8c6206002793f118c193cffe607fc6 Mon Sep 17 00:00:00 2001 From: Priyanshu Rai Date: Sun, 23 Aug 2026 18:17:00 +0530 Subject: [PATCH 15/20] fix: inherit dynamic colors in settings dialogs and simplify theme pickers Alert dialogs no longer pin colorPrimary to the static purple. Theme pickers are radio lists; SeekBar preferences use a Material 3 value TextView. --- .../fragment/AdvancedSettingsFragment.kt | 10 +- .../fragment/CustomizationSettingsFragment.kt | 104 ++----- .../fragment/GeneralSettingsFragment.kt | 6 +- .../PrivacyAndSecuritySettingsFragment.kt | 14 +- .../fragment/UnifiedWebAppFragment.kt | 8 +- .../main/res/layout/dialog_theme_picker.xml | 266 ------------------ .../res/layout/dialog_web_theme_picker.xml | 266 ------------------ .../layout/preference_seekbar_material3.xml | 58 ++++ app/src/main/res/values/style.xml | 57 +++- 9 files changed, 150 insertions(+), 639 deletions(-) delete mode 100644 app/src/main/res/layout/dialog_theme_picker.xml delete mode 100644 app/src/main/res/layout/dialog_web_theme_picker.xml create mode 100644 app/src/main/res/layout/preference_seekbar_material3.xml diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/AdvancedSettingsFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/AdvancedSettingsFragment.kt index 17ade37..ad8de1d 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/AdvancedSettingsFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/AdvancedSettingsFragment.kt @@ -131,7 +131,7 @@ class AdvancedSettingsFragment : BaseSettingsFragment() { } private fun sideloadXpiLink() { - val builder = MaterialAlertDialogBuilder(requireContext()) + val builder = MaterialAlertDialogBuilder(requireActivity()) builder.setTitle(resources.getString(R.string.load_xpi_link)) val input = EditText(requireContext()) @@ -152,7 +152,7 @@ class AdvancedSettingsFragment : BaseSettingsFragment() { for (i in addons) { if (i.id == it.id) { runOnUiThread { - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(resources.getString(R.string.error)) .setMessage(resources.getString(R.string.already_available)) .setNeutralButton(resources.getString(R.string.mozac_feature_prompts_ok)) { dialog, _ -> @@ -230,7 +230,7 @@ class AdvancedSettingsFragment : BaseSettingsFragment() { for (addon in addons) { if (addon.id == extension.id) { runOnUiThread { - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(resources.getString(R.string.error)) .setMessage(resources.getString(R.string.already_available)) .setNeutralButton(resources.getString(R.string.mozac_feature_prompts_ok)) { dialog, _ -> @@ -281,7 +281,7 @@ class AdvancedSettingsFragment : BaseSettingsFragment() { } private fun pickCollectionUser() { - val builder = MaterialAlertDialogBuilder(requireContext()) + val builder = MaterialAlertDialogBuilder(requireActivity()) builder.setTitle(resources.getString(R.string.collection_user)) val input = EditText(requireContext()) @@ -305,7 +305,7 @@ class AdvancedSettingsFragment : BaseSettingsFragment() { } private fun pickCollectionName() { - val builder = MaterialAlertDialogBuilder(requireContext()) + val builder = MaterialAlertDialogBuilder(requireActivity()) builder.setTitle(resources.getString(R.string.collection_name)) val input = EditText(requireContext()) diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/CustomizationSettingsFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/CustomizationSettingsFragment.kt index 64949c6..9467c98 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/CustomizationSettingsFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/CustomizationSettingsFragment.kt @@ -261,7 +261,7 @@ class CustomizationSettingsFragment : BaseSettingsFragment() { val addonNames = allAddons.map { it.value.name } val checkedItems = allAddons.map { allowedAddonIds.contains(it.value.id) }.toBooleanArray() - MaterialAlertDialogBuilder(context) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.bar_addon_list) .setMultiChoiceItems(addonNames.toTypedArray(), checkedItems) { _, _, _ -> // We'll handle the selection when the user clicks OK @@ -300,7 +300,7 @@ class CustomizationSettingsFragment : BaseSettingsFragment() { getString(R.string.homepage_background_url) ) - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(resources.getString(R.string.homepage_background_image)) .setItems(items) { _, which -> when (which) { @@ -351,7 +351,7 @@ class CustomizationSettingsFragment : BaseSettingsFragment() { inputLayout.hint = resources.getString(R.string.url) editText.setText(UserPreferences(requireContext()).homepageBackgroundUrl) - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(resources.getString(R.string.homepage_background_image)) .setView(dialogView) .setPositiveButton(resources.getString(R.string.mozac_feature_prompts_ok)) { _, _ -> @@ -379,50 +379,15 @@ class CustomizationSettingsFragment : BaseSettingsFragment() { } private fun pickAppTheme() { - val startingChoice = UserPreferences(requireContext()).appThemeChoice - val checkedItem = UserPreferences(requireContext()).appThemeChoice + val items = resources.getStringArray(R.array.theme_types) + var selectedChoice = UserPreferences(requireContext()).appThemeChoice - val dialogView = layoutInflater.inflate(R.layout.dialog_theme_picker, null) - val lightCard = dialogView.findViewById(R.id.lightThemeCard) - val darkCard = dialogView.findViewById(R.id.darkThemeCard) - val systemCard = dialogView.findViewById(R.id.systemThemeCard) - - // Set initial checked state - when (checkedItem) { - 0 -> lightCard.isChecked = true - 1 -> darkCard.isChecked = true - 2 -> systemCard.isChecked = true - } - - var selectedChoice = checkedItem - - lightCard.setOnClickListener { - lightCard.isChecked = true - darkCard.isChecked = false - systemCard.isChecked = false - selectedChoice = 0 - } - - darkCard.setOnClickListener { - lightCard.isChecked = false - darkCard.isChecked = true - systemCard.isChecked = false - selectedChoice = 1 - } - - systemCard.setOnClickListener { - lightCard.isChecked = false - darkCard.isChecked = false - systemCard.isChecked = true - selectedChoice = 2 - } - - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(resources.getString(R.string.theme)) - .setView(dialogView) - .setNegativeButton(resources.getString(R.string.cancel)) { _, _ -> - UserPreferences(requireContext()).appThemeChoice = startingChoice + .setSingleChoiceItems(items, selectedChoice) { _, which -> + selectedChoice = which } + .setNegativeButton(resources.getString(R.string.cancel), null) .setPositiveButton(resources.getString(R.string.mozac_feature_prompts_ok)) { _, _ -> UserPreferences(requireContext()).appThemeChoice = selectedChoice applyAppTheme(selectedChoice) @@ -450,7 +415,7 @@ class CustomizationSettingsFragment : BaseSettingsFragment() { updateIconPreview(previewIcon, value) } - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.toolbar_icon_size) .setView(dialogView) .setPositiveButton(R.string.mozac_feature_prompts_ok) { _, _ -> @@ -485,7 +450,7 @@ class CustomizationSettingsFragment : BaseSettingsFragment() { updateFontPreview(previewText, value) } - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.interface_font_scale) .setView(dialogView) .setPositiveButton(R.string.mozac_feature_prompts_ok) { _, _ -> @@ -515,50 +480,15 @@ class CustomizationSettingsFragment : BaseSettingsFragment() { } private fun pickWebTheme() { - val startingChoice = UserPreferences(requireContext()).webThemeChoice - val checkedItem = UserPreferences(requireContext()).webThemeChoice + val items = resources.getStringArray(R.array.theme_types) + var selectedChoice = UserPreferences(requireContext()).webThemeChoice - val dialogView = layoutInflater.inflate(R.layout.dialog_web_theme_picker, null) - val lightCard = dialogView.findViewById(R.id.lightThemeCard) - val darkCard = dialogView.findViewById(R.id.darkThemeCard) - val systemCard = dialogView.findViewById(R.id.systemThemeCard) - - // Set initial checked state - when (checkedItem) { - 0 -> lightCard.isChecked = true - 1 -> darkCard.isChecked = true - 2 -> systemCard.isChecked = true - } - - var selectedChoice = checkedItem - - lightCard.setOnClickListener { - lightCard.isChecked = true - darkCard.isChecked = false - systemCard.isChecked = false - selectedChoice = 0 - } - - darkCard.setOnClickListener { - lightCard.isChecked = false - darkCard.isChecked = true - systemCard.isChecked = false - selectedChoice = 1 - } - - systemCard.setOnClickListener { - lightCard.isChecked = false - darkCard.isChecked = false - systemCard.isChecked = true - selectedChoice = 2 - } - - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(resources.getString(R.string.web_theme)) - .setView(dialogView) - .setNegativeButton(resources.getString(R.string.cancel)) { _, _ -> - UserPreferences(requireContext()).webThemeChoice = startingChoice + .setSingleChoiceItems(items, selectedChoice) { _, which -> + selectedChoice = which } + .setNegativeButton(resources.getString(R.string.cancel), null) .setPositiveButton(resources.getString(R.string.mozac_feature_prompts_ok)) { _, _ -> UserPreferences(requireContext()).webThemeChoice = selectedChoice } diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt index bb76948..5b0ad3b 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt @@ -113,7 +113,7 @@ class GeneralSettingsFragment : BaseSettingsFragment() { val singleItems = resources.getStringArray(R.array.homepage_types).toMutableList() val checkedItem = UserPreferences(requireContext()).homepageType - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(resources.getString(R.string.homepage_type)) .setNeutralButton(resources.getString(R.string.cancel)) { _, _ -> UserPreferences(requireContext()).homepageType = startingChoice @@ -145,7 +145,7 @@ class GeneralSettingsFragment : BaseSettingsFragment() { prefs.searchEngineChoice } - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(if (private) getString(R.string.private_search_engine) else getString(R.string.search_engine)) .setNeutralButton(resources.getString(R.string.cancel), null) .setPositiveButton(resources.getString(R.string.mozac_feature_prompts_ok)) { _, _ -> @@ -171,7 +171,7 @@ class GeneralSettingsFragment : BaseSettingsFragment() { } fun customSearchEngineDialog(){ - val builder = AlertDialog.Builder(context) + val builder = MaterialAlertDialogBuilder(requireActivity()) builder.setTitle(R.string.custom_search_engine) builder.setMessage(R.string.custom_search_engine_details) diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/PrivacyAndSecuritySettingsFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/PrivacyAndSecuritySettingsFragment.kt index 9134a07..db04e3c 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/PrivacyAndSecuritySettingsFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/PrivacyAndSecuritySettingsFragment.kt @@ -89,7 +89,7 @@ class PrivacyAndSecuritySettingsFragment : BaseSettingsFragment() { private fun showHttpsOnlyPicker(prefs: UserPreferences) { val items = resources.getStringArray(R.array.https_only_mode_names) - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.https_only_title) .setSingleChoiceItems(items, prefs.httpsOnlyMode) { dialog, which -> prefs.httpsOnlyMode = which @@ -104,7 +104,7 @@ class PrivacyAndSecuritySettingsFragment : BaseSettingsFragment() { private fun showDohModePicker(prefs: UserPreferences) { val items = resources.getStringArray(R.array.doh_mode_names) - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.doh_title) .setSingleChoiceItems(items, prefs.dohMode) { dialog, which -> prefs.dohMode = which @@ -135,7 +135,7 @@ class PrivacyAndSecuritySettingsFragment : BaseSettingsFragment() { else -> 2 } - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.doh_provider_title) .setSingleChoiceItems(labels, checked) { dialog, which -> if (which == 2) { @@ -164,7 +164,7 @@ class PrivacyAndSecuritySettingsFragment : BaseSettingsFragment() { ) } - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.doh_provider_custom) .setView(input) .setPositiveButton(R.string.mozac_feature_prompts_ok) { _, _ -> @@ -240,7 +240,7 @@ class PrivacyAndSecuritySettingsFragment : BaseSettingsFragment() { } private fun showEtpLevelPicker(prefs: UserPreferences, customCategories: PreferenceCategory?) { - val ctx = requireContext() + val ctx = requireActivity() val currentLevel = prefs.etpLevel // Resolve Material 3 theme colours @@ -355,7 +355,7 @@ class PrivacyAndSecuritySettingsFragment : BaseSettingsFragment() { } private fun clearTabs(){ - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(requireActivity()) .setTitle(resources.getString(R.string.clear_tabs)) .setNegativeButton(resources.getString(R.string.cancel)) { _, _ -> } .setPositiveButton(resources.getString(R.string.mozac_feature_prompts_ok)) { _, _ -> @@ -391,7 +391,7 @@ class PrivacyAndSecuritySettingsFragment : BaseSettingsFragment() { spinner.adapter = adapter } - val historyDialog = MaterialAlertDialogBuilder(requireContext()) + val historyDialog = MaterialAlertDialogBuilder(requireActivity()) .setView(layout) .create() diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/UnifiedWebAppFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/UnifiedWebAppFragment.kt index 20eb799..09354c0 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/UnifiedWebAppFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/UnifiedWebAppFragment.kt @@ -220,7 +220,7 @@ class UnifiedWebAppFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { // Clear web app data - this would involve clearing service worker caches, localStorage, etc. // For now, just show confirmation - com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext()) + com.google.android.material.dialog.MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.success) .setMessage(R.string.web_app_data_cleared) .setPositiveButton(android.R.string.ok, null) @@ -420,7 +420,7 @@ class UnifiedWebAppFragment : Fragment() { Components(requireContext()).webAppManager.updateWebApp( webApp.copy(profileId = profileId) ) - com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext()) + com.google.android.material.dialog.MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.success) .setMessage(R.string.profile_associated) .setPositiveButton(android.R.string.ok, null) @@ -455,13 +455,13 @@ class UnifiedWebAppFragment : Fragment() { profileId = newProfileId ) - com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext()) + com.google.android.material.dialog.MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.success) .setMessage(getString(R.string.web_app_cloned_success, newName)) .setPositiveButton(android.R.string.ok, null) .show() } catch (e: Exception) { - com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext()) + com.google.android.material.dialog.MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.error) .setMessage(e.message ?: getString(R.string.unknown_error)) .setPositiveButton(android.R.string.ok, null) diff --git a/app/src/main/res/layout/dialog_theme_picker.xml b/app/src/main/res/layout/dialog_theme_picker.xml deleted file mode 100644 index d56899b..0000000 --- a/app/src/main/res/layout/dialog_theme_picker.xml +++ /dev/null @@ -1,266 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_web_theme_picker.xml b/app/src/main/res/layout/dialog_web_theme_picker.xml deleted file mode 100644 index d56899b..0000000 --- a/app/src/main/res/layout/dialog_web_theme_picker.xml +++ /dev/null @@ -1,266 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/preference_seekbar_material3.xml b/app/src/main/res/layout/preference_seekbar_material3.xml new file mode 100644 index 0000000..7310866 --- /dev/null +++ b/app/src/main/res/layout/preference_seekbar_material3.xml @@ -0,0 +1,58 @@ + + + + + + + + + diff --git a/app/src/main/res/values/style.xml b/app/src/main/res/values/style.xml index fa9f91c..7c901cf 100644 --- a/app/src/main/res/values/style.xml +++ b/app/src/main/res/values/style.xml @@ -67,6 +67,13 @@ @style/Widget.Material3.CompoundButton.CheckBox @style/Widget.Material3.CompoundButton.MaterialSwitch + @style/Widget.App.RadioButton + @style/Widget.App.SeekBar + @style/Widget.Material3.Slider + ?attr/colorPrimary + ?attr/colorPrimary + @style/Theme.App.MaterialAlertDialog + @style/ThemeOverlay.App.MaterialAlertDialog @style/Widget.Material3.FloatingActionButton.Primary @@ -97,15 +104,52 @@ + + + + + + + + + +